@driftengine/core 4.13.2 → 4.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.json +11 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +1 -0
  5. package/dist/render/backend/webgl2/renderer.d.ts +12 -0
  6. package/dist/render/backend/webgl2/renderer.js +47 -2
  7. package/dist/render/backend/webgpu/renderer.d.ts +9 -0
  8. package/dist/render/backend/webgpu/renderer.js +33 -1
  9. package/dist/render/shaders/flat/detail.d.ts +21 -0
  10. package/dist/render/shaders/flat/detail.js +74 -0
  11. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  12. package/dist/render/shaders/flat/glassTint.js +1 -0
  13. package/dist/render/shaders/flat/index.d.ts +5 -0
  14. package/dist/render/shaders/flat/index.js +3 -0
  15. package/dist/render/shaders/flat/main.d.ts +1 -1
  16. package/dist/render/shaders/flat/main.js +2 -0
  17. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  18. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  19. package/dist/render/surfaceDetail.d.ts +79 -0
  20. package/dist/render/surfaceDetail.js +86 -0
  21. package/dist/render/surfaceTexture.d.ts +9 -0
  22. package/package.json +6 -6
  23. package/src/index.ts +2 -0
  24. package/src/render/backend/webgl2/renderer.ts +58 -2
  25. package/src/render/backend/webgpu/renderer.ts +42 -1
  26. package/src/render/shaders/flat/detail.ts +74 -0
  27. package/src/render/shaders/flat/glassTint.ts +3 -1
  28. package/src/render/shaders/flat/index.ts +8 -0
  29. package/src/render/shaders/flat/main.ts +2 -0
  30. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  31. package/src/render/surfaceDetail.ts +136 -0
  32. package/src/render/surfaceTexture.ts +9 -0
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A material's tiled detail: small normal maps, and occlusion, repeated many times over the base
3
+ * maps, a region map choosing which one each texel wears. `SurfaceMaterial.detail`, packed into
4
+ * `uDetail` for `shaders/flat/detail.ts`.
5
+ *
6
+ * **What it is for.** A surface's finest grain — leather, a weave, brushed metal, a face's pores —
7
+ * is a map of a few hundred texels repeated tens of times across it. Baked into the base maps it is
8
+ * lost: a 256-texel detail tiled forty times over a 2048-texel map would want some ten thousand
9
+ * texels a side. So the base maps carry the shape and a detail map the grain, each garment region
10
+ * with its own grain at its own repeat, which is how such surfaces are authored.
11
+ *
12
+ * **Where the maps are.** The lit stage has no texture binding to spare on either backend, so the
13
+ * detail maps are layers of the array read where a shading model's map is (`uModelMap`), and the
14
+ * region map is a layer of the material's own ORM array, one index a texel in its red. What that
15
+ * gives up, said where each is refused: a material whose shading model has a map must put its
16
+ * detail layers in that same array; a layered material whose mask is a map cannot have detail; and
17
+ * the region map shares the ORM array's size and format, which must keep an index exactly, so an
18
+ * uncompressed one.
19
+ *
20
+ * **Sixteen regions, two vectors each**, behind one vector saying how many and where the region
21
+ * map is: thirty-three in all, which is why WebGL2 compiles them in only when a material first asks
22
+ * and refuses them on a part with no room for them.
23
+ */
24
+ /** Regions a material may name. */
25
+ export declare const MAX_DETAIL_REGIONS = 16;
26
+ /** Floats `uDetail` holds: a header vector, then two vectors a region. */
27
+ export declare const DETAIL_FLOATS: number;
28
+ /** One region's detail. */
29
+ export interface SurfaceDetailRegion {
30
+ /** The layer of the detail array holding this region's tangent-space normal map. */
31
+ readonly normalLayer: number;
32
+ /** The layer holding its occlusion, in red; absent, the region adds none. */
33
+ readonly occlusionLayer?: number;
34
+ /** Repeats across the base coordinates, the same both ways or across and down apart. */
35
+ readonly repeat: number | readonly [number, number];
36
+ /** Added to the repeated coordinates, so neighbouring regions need not line up. Default none. */
37
+ readonly offset?: readonly [number, number];
38
+ /** How far the detail turns the normal: 0 lies flat, 1 as drawn, more steeper. Default 1. */
39
+ readonly normalStrength?: number;
40
+ /** How much of its occlusion is laid on, from 0 to 1. Default 1. */
41
+ readonly occlusionStrength?: number;
42
+ }
43
+ /** A material's tiled detail. */
44
+ export interface SurfaceDetail<Texture> {
45
+ /**
46
+ * The array whose layers the regions name, read where a shading model's map is. A material with
47
+ * a `modelMap` puts its detail layers in that array and names it here too.
48
+ */
49
+ readonly maps: Texture;
50
+ /**
51
+ * The layer of the material's ORM array holding the region map: in its red, each texel's region
52
+ * as an index from 0, stored as `index / 255`, read exactly at the base coordinates. Absent, every
53
+ * texel is region 0, which is a surface with one detail all over: a face's pores.
54
+ */
55
+ readonly regionLayer?: number;
56
+ /** Up to sixteen, by index. A texel naming a region past the last wears the last. */
57
+ readonly regions: readonly SurfaceDetailRegion[];
58
+ }
59
+ /**
60
+ * Pack a material's detail into `out`, `DETAIL_FLOATS` long; zeros for none, which the shader reads
61
+ * as no detail. `hasOrm` says whether the material carries the ORM array a region map is a layer
62
+ * of; without one every texel is region 0. Allocates nothing.
63
+ */
64
+ export declare function packSurfaceDetail(detail: SurfaceDetail<unknown> | null, hasOrm: boolean, out: Float32Array): void;
65
+ /** Why a material's detail is drawn without, or null where nothing stands in its way. */
66
+ export type DetailRefusal = 'slot' | 'projection';
67
+ /**
68
+ * Whether a material's detail can be drawn, given what else wants the slot its maps are read
69
+ * through: a shading model's own map, or a layered material's mask where it is a map. Either must
70
+ * be the detail's array itself. And a material placed by the world has no base coordinates for a
71
+ * region map to be read at, so its detail is refused too.
72
+ */
73
+ export declare function detailRefusal(detail: SurfaceDetail<unknown>, slotTakenBy: unknown, projected: boolean): DetailRefusal | null;
74
+ /** A material's detail, or null where it names none or no region. */
75
+ export declare function detailOf<Texture>(material: {
76
+ readonly detail?: SurfaceDetail<Texture> | null;
77
+ } | null): SurfaceDetail<Texture> | null;
78
+ /** The sentence each backend says once when it draws a material without its detail. */
79
+ export declare function detailRefusalWarning(refusal: DetailRefusal): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A material's tiled detail: small normal maps, and occlusion, repeated many times over the base
3
+ * maps, a region map choosing which one each texel wears. `SurfaceMaterial.detail`, packed into
4
+ * `uDetail` for `shaders/flat/detail.ts`.
5
+ *
6
+ * **What it is for.** A surface's finest grain — leather, a weave, brushed metal, a face's pores —
7
+ * is a map of a few hundred texels repeated tens of times across it. Baked into the base maps it is
8
+ * lost: a 256-texel detail tiled forty times over a 2048-texel map would want some ten thousand
9
+ * texels a side. So the base maps carry the shape and a detail map the grain, each garment region
10
+ * with its own grain at its own repeat, which is how such surfaces are authored.
11
+ *
12
+ * **Where the maps are.** The lit stage has no texture binding to spare on either backend, so the
13
+ * detail maps are layers of the array read where a shading model's map is (`uModelMap`), and the
14
+ * region map is a layer of the material's own ORM array, one index a texel in its red. What that
15
+ * gives up, said where each is refused: a material whose shading model has a map must put its
16
+ * detail layers in that same array; a layered material whose mask is a map cannot have detail; and
17
+ * the region map shares the ORM array's size and format, which must keep an index exactly, so an
18
+ * uncompressed one.
19
+ *
20
+ * **Sixteen regions, two vectors each**, behind one vector saying how many and where the region
21
+ * map is: thirty-three in all, which is why WebGL2 compiles them in only when a material first asks
22
+ * and refuses them on a part with no room for them.
23
+ */
24
+ /** Regions a material may name. */
25
+ export const MAX_DETAIL_REGIONS = 16;
26
+ /** Floats `uDetail` holds: a header vector, then two vectors a region. */
27
+ export const DETAIL_FLOATS = 4 + MAX_DETAIL_REGIONS * 8;
28
+ /**
29
+ * Pack a material's detail into `out`, `DETAIL_FLOATS` long; zeros for none, which the shader reads
30
+ * as no detail. `hasOrm` says whether the material carries the ORM array a region map is a layer
31
+ * of; without one every texel is region 0. Allocates nothing.
32
+ */
33
+ export function packSurfaceDetail(detail, hasOrm, out) {
34
+ out.fill(0);
35
+ if (detail === null)
36
+ return;
37
+ const count = Math.min(MAX_DETAIL_REGIONS, detail.regions.length);
38
+ out[0] = count;
39
+ const regionLayer = detail.regionLayer;
40
+ out[1] = hasOrm && regionLayer !== undefined ? Math.max(0, Math.floor(regionLayer)) : -1;
41
+ for (let i = 0; i < count; i++) {
42
+ const region = detail.regions[i];
43
+ const at = 4 + i * 8;
44
+ const repeat = region.repeat;
45
+ const across = typeof repeat === 'number' ? repeat : repeat[0];
46
+ const down = typeof repeat === 'number' ? repeat : repeat[1];
47
+ out[at] = Math.max(0, Math.floor(region.normalLayer));
48
+ out[at + 1] =
49
+ region.occlusionLayer === undefined ? -1 : Math.max(0, Math.floor(region.occlusionLayer));
50
+ out[at + 2] = Math.max(0, region.normalStrength ?? 1);
51
+ out[at + 3] = Math.min(1, Math.max(0, region.occlusionStrength ?? 1));
52
+ /* A repeat at or below zero has no grain to show, so it is one rather than a mirror or nothing. */
53
+ out[at + 4] = across > 0 ? across : 1;
54
+ out[at + 5] = down > 0 ? down : 1;
55
+ out[at + 6] = region.offset?.[0] ?? 0;
56
+ out[at + 7] = region.offset?.[1] ?? 0;
57
+ }
58
+ }
59
+ /**
60
+ * Whether a material's detail can be drawn, given what else wants the slot its maps are read
61
+ * through: a shading model's own map, or a layered material's mask where it is a map. Either must
62
+ * be the detail's array itself. And a material placed by the world has no base coordinates for a
63
+ * region map to be read at, so its detail is refused too.
64
+ */
65
+ export function detailRefusal(detail, slotTakenBy, projected) {
66
+ if (projected)
67
+ return 'projection';
68
+ if (slotTakenBy !== null && slotTakenBy !== undefined && slotTakenBy !== detail.maps) {
69
+ return 'slot';
70
+ }
71
+ return null;
72
+ }
73
+ /** A material's detail, or null where it names none or no region. */
74
+ export function detailOf(material) {
75
+ const detail = material?.detail ?? null;
76
+ return detail === null || detail.regions.length === 0 ? null : detail;
77
+ }
78
+ /** The sentence each backend says once when it draws a material without its detail. */
79
+ export function detailRefusalWarning(refusal) {
80
+ return refusal === 'slot'
81
+ ? "Renderer: a material's detail is drawn without: its maps are read where a shading model's " +
82
+ "map goes, and this material binds another array there, its `modelMap` or its layers' " +
83
+ 'mask. Put the detail layers in the `modelMap` array and name that array as `detail.maps`.'
84
+ : "Renderer: a material's detail is drawn without: it is placed by the world (`projection`), " +
85
+ 'so there are no base coordinates for its regions and repeats to be read at.';
86
+ }
@@ -8,6 +8,7 @@
8
8
  * array samples exactly as a 2D texture does. A mesh names its layer per vertex (`MeshData.layers`),
9
9
  * so a merged block wearing forty facades is one draw.
10
10
  */
11
+ import type { SurfaceDetail } from './surfaceDetail.ts';
11
12
  import type { SurfaceLayers } from './surfaceLayers.ts';
12
13
  import type { SurfaceProjection } from './surfaceProjection.ts';
13
14
  import type { SurfaceModel } from './surfaceModel.ts';
@@ -235,6 +236,14 @@ export interface SurfaceMaterial<Texture = SurfaceTexture> {
235
236
  * vertices. See `surfaceLayers.ts` for the rest and what it gives up.
236
237
  */
237
238
  layers?: SurfaceLayers<Texture> | null;
239
+ /**
240
+ * Fine grain tiled over the base maps: up to sixteen regions, each a detail normal map, and an
241
+ * occlusion, at its own repeat and strength, a region map in the ORM array choosing which one each
242
+ * texel wears. The detail maps are layers of an array read where `modelMap` goes, so a material
243
+ * with a shading model's map puts them in that array. **A lit switch**, compiled in the first time
244
+ * a material asks. See `surfaceDetail.ts` for the rest and what it gives up.
245
+ */
246
+ detail?: SurfaceDetail<Texture> | null;
238
247
  }
239
248
  export interface SurfaceTextureOptions {
240
249
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@driftengine/core",
3
- "version": "4.13.2",
3
+ "version": "4.13.3",
4
4
  "description": "The runtime of DriftEngine, a WebGPU game engine with a WebGL2 fallback: renderer, loop, input, cameras, scene graph and physics",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,8 +29,8 @@
29
29
  ],
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
- "@driftengine/drft": "4.13.2",
33
- "@driftengine/physics": "4.13.2",
32
+ "@driftengine/drft": "4.13.3",
33
+ "@driftengine/physics": "4.13.3",
34
34
  "gl-matrix": "^3.4.3"
35
35
  },
36
36
  "author": "Drift Technologies",
@@ -67,8 +67,8 @@
67
67
  "prepack": "node ../../scripts/build.mjs @driftengine/core"
68
68
  },
69
69
  "devDependencies": {
70
- "@driftengine/nav": "4.13.2",
71
- "@driftengine/network": "4.13.2",
72
- "@driftengine/texture": "4.13.2"
70
+ "@driftengine/nav": "4.13.3",
71
+ "@driftengine/network": "4.13.3",
72
+ "@driftengine/texture": "4.13.3"
73
73
  }
74
74
  }
package/src/index.ts CHANGED
@@ -677,6 +677,8 @@ export { SurfaceTexture } from './render/surfaceTexture.ts';
677
677
  export type { SurfaceMaterial, SurfaceTextureOptions } from './render/surfaceTexture.ts';
678
678
  export type { SurfaceProjection } from './render/surfaceProjection.ts';
679
679
  export type { SurfaceLayers } from './render/surfaceLayers.ts';
680
+ export type { SurfaceDetail, SurfaceDetailRegion } from './render/surfaceDetail.ts';
681
+ export { MAX_DETAIL_REGIONS } from './render/surfaceDetail.ts';
680
682
  export type { SurfaceLayerLook } from './render/surfaceLayerLooks.ts';
681
683
  /*
682
684
  * Compressed textures — BC, ETC2 and EAC, ASTC: the source `createSurfaceTexture` takes as blocks,
@@ -117,6 +117,15 @@ import {
117
117
  packSurfaceLayers,
118
118
  } from '../../surfaceLayers.ts';
119
119
  import type { SurfaceLayers } from '../../surfaceLayers.ts';
120
+ import {
121
+ DETAIL_FLOATS,
122
+ detailOf,
123
+ detailRefusal,
124
+ detailRefusalWarning,
125
+ packSurfaceDetail,
126
+ type DetailRefusal,
127
+ type SurfaceDetail,
128
+ } from '../../surfaceDetail.ts';
120
129
  import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.ts';
121
130
  import type { SurfaceProjection } from '../../surfaceProjection.ts';
122
131
  import { FILM_FRAG, FILM_VERT } from '../../shaders/film.ts';
@@ -1801,6 +1810,13 @@ export class WebGL2Renderer implements RendererApi {
1801
1810
  /** Whether it has room for the layers' looks beside them, and whether the refusal was said. */
1802
1811
  private looksRoom: boolean | null = null;
1803
1812
  private warnedLooksRoom = false;
1813
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
1814
+ private readonly detailFloats = new Float32Array(DETAIL_FLOATS);
1815
+ /** Whether the lit stage has room for detail on this part, asked once. See `detailOfMaterial`. */
1816
+ private detailRoom: boolean | null = null;
1817
+ private warnedDetailRoom = false;
1818
+ /** The refusals already said, each once. */
1819
+ private readonly warnedDetail = new Set<DetailRefusal>();
1804
1820
  private warnedLayersModelMap = false;
1805
1821
  private warnedLayersTriplanar = false;
1806
1822
  private warnedOverlayRoom = false;
@@ -4462,10 +4478,16 @@ export class WebGL2Renderer implements RendererApi {
4462
4478
  const modelMap = material?.modelMap ?? null;
4463
4479
  /* A layered material's mask, where it is a map, goes where a model's would: surfaceLayers.ts. */
4464
4480
  const layers = this.layersOf(material);
4465
- const boundMap = modelMap ?? layerMaskMap(layers);
4481
+ const taken = modelMap ?? layerMaskMap(layers);
4482
+ /* And its detail's array, where nothing else is read there: surfaceDetail.ts. */
4483
+ const detail = this.detailOfMaterial(material, taken);
4484
+ const boundMap = taken ?? detail?.maps ?? null;
4466
4485
  gl.activeTexture(gl.TEXTURE0 + MODEL_MAP_TEXTURE_UNIT);
4467
4486
  gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.emptyTexture2DArray);
4468
4487
  if (boundMap !== null) boundMap.bind(gl, MODEL_MAP_TEXTURE_UNIT);
4488
+ packSurfaceDetail(detail, (material?.orm ?? null) !== null, this.detailFloats);
4489
+ if (detail !== null) this.askLit('SURFACE_DETAIL');
4490
+ gl.uniform4fv(u['uDetail'] ?? null, this.detailFloats);
4469
4491
  packSurfaceLayers(layers, this.layerFloats);
4470
4492
  if (layers !== null) this.askLit('LAYERED');
4471
4493
  gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
@@ -4771,6 +4793,37 @@ export class WebGL2Renderer implements RendererApi {
4771
4793
  * as they are, said once — which on a material sharing its arrays reads the wrong textures, so
4772
4794
  * the sentence names the way out.
4773
4795
  */
4796
+ /**
4797
+ * A material's detail, or null where it is refused: its slot holds another array, it is placed by
4798
+ * the world, or the lit stage has no room for its thirty-three vectors. Each said once.
4799
+ */
4800
+ private detailOfMaterial(
4801
+ material: SurfaceMaterial | null,
4802
+ taken: SurfaceTexture | null,
4803
+ ): SurfaceDetail<SurfaceTexture> | null {
4804
+ const detail = detailOf(material) as SurfaceDetail<SurfaceTexture> | null;
4805
+ if (detail === null) return null;
4806
+ const refusal = detailRefusal(detail, taken, (material?.projection ?? null) !== null);
4807
+ if (refusal !== null) {
4808
+ if (!this.warnedDetail.has(refusal)) {
4809
+ this.warnedDetail.add(refusal);
4810
+ console.warn(detailRefusalWarning(refusal));
4811
+ }
4812
+ return null;
4813
+ }
4814
+ this.detailRoom ??= this.switchFits('SURFACE_DETAIL');
4815
+ if (this.detailRoom) return detail;
4816
+ if (!this.warnedDetailRoom) {
4817
+ this.warnedDetailRoom = true;
4818
+ console.warn(
4819
+ "Renderer: a material's detail is refused on this device. It takes thirty-three fragment " +
4820
+ 'uniform vectors the lit shader does not have, so the material is drawn without it. A ' +
4821
+ 'lower `maxLights` makes the room, or WebGPU has it.',
4822
+ );
4823
+ }
4824
+ return null;
4825
+ }
4826
+
4774
4827
  private looksFit(layers: SurfaceLayers<SurfaceTexture> | null): boolean {
4775
4828
  if (!asksLayerLooks(layers)) return false;
4776
4829
  this.looksRoom ??= this.switchFits('LAYER_LOOKS', 'LAYERED');
@@ -7121,6 +7174,7 @@ export class WebGL2Renderer implements RendererApi {
7121
7174
  movingSun: asIf.MOVING_SUN ?? this.litOn.MOVING_SUN,
7122
7175
  layered: asIf.LAYERED ?? this.litOn.LAYERED,
7123
7176
  layerLooks: asIf.LAYER_LOOKS ?? this.litOn.LAYER_LOOKS,
7177
+ surfaceDetail: asIf.SURFACE_DETAIL ?? this.litOn.SURFACE_DETAIL,
7124
7178
  maxLights: budget.maxLights,
7125
7179
  maxAreaLights: budget.maxAreaLights,
7126
7180
  });
@@ -9287,9 +9341,10 @@ export class WebGL2Renderer implements RendererApi {
9287
9341
  this.projection.fill(0);
9288
9342
  /* Nor light through from behind. See `SurfaceMaterial.diffuseTransmission`. */
9289
9343
  resolveTransmission(null, this.transmission);
9290
- /* Nor layers: one layer until a material asks for more. */
9344
+ /* Nor layers: one layer until a material asks for more. Nor detail. */
9291
9345
  this.layerFloats.fill(0);
9292
9346
  this.lookFloats.fill(0);
9347
+ this.detailFloats.fill(0);
9293
9348
  this.overlayMaps = null;
9294
9349
  this.overlayTexture = null;
9295
9350
  /* Nor an ambient of its own: the frame's, until a draw asks. See `setAmbientSH`. */
@@ -9465,6 +9520,7 @@ export class WebGL2Renderer implements RendererApi {
9465
9520
  gl.uniform4fv(u['uWorldUv'] ?? null, this.projection);
9466
9521
  gl.uniform4fv(u['uLayers'] ?? null, this.layerFloats);
9467
9522
  gl.uniform4fv(u['uLayerLooks'] ?? null, this.lookFloats);
9523
+ gl.uniform4fv(u['uDetail'] ?? null, this.detailFloats);
9468
9524
  /* Neither refracting nor glass, which is every draw that does not say otherwise. See
9469
9525
  `bindSeeThrough` for the four numbers in the one vector. */
9470
9526
  gl.uniform4f(u['uSeeThrough'] ?? null, 0, 0, 0, 0);
@@ -216,6 +216,15 @@ import {
216
216
  packSurfaceLayers,
217
217
  } from '../../surfaceLayers.ts';
218
218
  import type { SurfaceLayers } from '../../surfaceLayers.ts';
219
+ import {
220
+ DETAIL_FLOATS,
221
+ detailOf,
222
+ detailRefusal,
223
+ detailRefusalWarning,
224
+ packSurfaceDetail,
225
+ type DetailRefusal,
226
+ type SurfaceDetail,
227
+ } from '../../surfaceDetail.ts';
219
228
  import { LOOK_FLOATS, asksLayerLooks, packLayerLooks } from '../../surfaceLayerLooks.ts';
220
229
  import {
221
230
  frameReflectionsRefused,
@@ -3038,6 +3047,10 @@ export class WebGPURenderer implements RendererApi {
3038
3047
  */
3039
3048
  private readonly transmission = new Float32Array([-1, -1, -1, 0]);
3040
3049
  private warnedLayersModelMap = false;
3050
+ /** `uDetail`, the material's tiled detail packed by `packSurfaceDetail`. */
3051
+ private readonly detailFloats = new Float32Array(DETAIL_FLOATS);
3052
+ /** The detail refusals already said, each once. See `detailOfMaterial`. */
3053
+ private readonly warnedDetail = new Set<DetailRefusal>();
3041
3054
  private warnedLayersTriplanar = false;
3042
3055
  /** Its numbers, `uModelParams`, packed by `packModel`. */
3043
3056
  private readonly modelParams = new Float32Array(MODEL_PARAM_FLOATS);
@@ -3516,7 +3529,10 @@ export class WebGPURenderer implements RendererApi {
3516
3529
  const ownModelMap = (material?.modelMap ?? null) as GpuSurfaceTexture | null;
3517
3530
  /* A layered material's mask, where it is a map, goes where a model's would: surfaceLayers.ts. */
3518
3531
  const layers = this.layersOf(material);
3519
- const modelMap = ownModelMap ?? layerMaskMap(layers);
3532
+ const taken = ownModelMap ?? layerMaskMap(layers);
3533
+ /* And its detail's array, where nothing else is read there: surfaceDetail.ts. */
3534
+ const detail = this.detailOfMaterial(material, taken);
3535
+ const modelMap = taken ?? detail?.maps ?? null;
3520
3536
  this.materialModel = material?.model ?? null;
3521
3537
  const reflectivity = material?.reflectivity;
3522
3538
  const environmentGain = material?.environmentGain;
@@ -3609,6 +3625,10 @@ export class WebGPURenderer implements RendererApi {
3609
3625
  packLayerLooks(layers, this.lookFloats);
3610
3626
  f.set(this.lookFloats, this.materialField('uLayerLooks'));
3611
3627
  if (asksLayerLooks(layers)) this.enableLit('LAYER_LOOKS');
3628
+ /* And its tiled detail, the first time a material lays any: detail.ts. */
3629
+ packSurfaceDetail(detail, orm !== null, this.detailFloats);
3630
+ f.set(this.detailFloats, this.materialField('uDetail'));
3631
+ if (detail !== null) this.enableLit('SURFACE_DETAIL');
3612
3632
  f.set(this.modelParams, this.materialField('uModelParams'));
3613
3633
  });
3614
3634
  /*
@@ -6395,6 +6415,25 @@ export class WebGPURenderer implements RendererApi {
6395
6415
  * A material's layers where they can be drawn: none asked, or a model's map in the mask's place,
6396
6416
  * said once, leave one layer.
6397
6417
  */
6418
+ /**
6419
+ * A material's detail, or null where it is refused: its slot holds another array, or it is placed
6420
+ * by the world. Each said once. This backend has room for its vectors wherever it runs.
6421
+ */
6422
+ private detailOfMaterial(
6423
+ material: SurfaceMaterial<GpuSurfaceTexture> | null,
6424
+ taken: GpuSurfaceTexture | null,
6425
+ ): SurfaceDetail<GpuSurfaceTexture> | null {
6426
+ const detail = detailOf(material);
6427
+ if (detail === null) return null;
6428
+ const refusal = detailRefusal(detail, taken, (material?.projection ?? null) !== null);
6429
+ if (refusal === null) return detail;
6430
+ if (!this.warnedDetail.has(refusal)) {
6431
+ this.warnedDetail.add(refusal);
6432
+ console.warn(detailRefusalWarning(refusal));
6433
+ }
6434
+ return null;
6435
+ }
6436
+
6398
6437
  private layersOf(
6399
6438
  material: SurfaceMaterial<GpuSurfaceTexture> | null,
6400
6439
  ): SurfaceLayers<GpuSurfaceTexture> | null {
@@ -9110,6 +9149,8 @@ export class WebGPURenderer implements RendererApi {
9110
9149
  /* Nor layers: one layer until a material asks for more. */
9111
9150
  f.fill(0, at('uLayers'), at('uLayers') + LAYER_FLOATS);
9112
9151
  f.fill(0, at('uLayerLooks'), at('uLayerLooks') + LOOK_FLOATS);
9152
+ /* Nor detail. */
9153
+ f.fill(0, at('uDetail'), at('uDetail') + DETAIL_FLOATS);
9113
9154
  f[at('uCutout')] = 0;
9114
9155
  f[at('uCutout') + 1] = 0;
9115
9156
  this.cutoutResolveStaged = 'hard';
@@ -0,0 +1,74 @@
1
+ /**
2
+ * A material's tiled detail over whatever its maps made: `SurfaceMaterial.detail`, packed into
3
+ * `uDetail` by `surfaceDetail.ts`.
4
+ *
5
+ * **Each texel wears its own region's detail alone.** The region is read exactly from the ORM
6
+ * array's layer the material names, at the base coordinates, so an index is never blended with its
7
+ * neighbour's at a region's edge; then that region's normal map is read at its repeat and offset,
8
+ * turned through a frame built on the normal the base maps made — the construction a layered
9
+ * material lays its layers over a mesh normal with — and its occlusion multiplied into the
10
+ * surface's. A strength scales the detail's slopes as a layer's look does.
11
+ *
12
+ * **Read with explicit gradients**, the base coordinates' times the region's repeat, because the
13
+ * region is the pixel's own: a read whose level is implicit is legal only in uniform control flow
14
+ * (AGENTS.md 2026-08-07), and across a region's edge the repeated coordinates jump, which would
15
+ * read a level as coarse as the jump. The frame and the gradients are taken first, under the
16
+ * uniform branch that calls this.
17
+ *
18
+ * **A lit switch, `SURFACE_DETAIL`**, off until a material first asks, declared after `LAYERED` and
19
+ * `LAYER_LOOKS` so each keeps its id. Compiled out, its thirty-three vectors are not declared.
20
+ */
21
+ export function detailGlsl(on: boolean): string {
22
+ if (!on) {
23
+ return /* glsl */ `
24
+ const bool SURFACE_DETAIL = false; // wgsl:override
25
+ bool detailed() { return false; }
26
+ void surfaceDetail(inout vec3 n, inout float occlusion, vec2 uv) {}
27
+ `;
28
+ }
29
+ return /* glsl */ `
30
+ /* Whether this program lays a material's detail. Off but where one has asked: detail.ts. */
31
+ const bool SURFACE_DETAIL = true; // wgsl:override
32
+ /*
33
+ * Thirty-three vectors, as surfaceDetail.ts packs them: the region count (x) and the ORM array's
34
+ * layer holding the region map, or -1 for none (y); then two a region, the normal map's layer (x),
35
+ * the occlusion's or -1 (y), the normal strength (z) and the occlusion strength (w); its repeats
36
+ * across and down (x y) and its offset (z w).
37
+ */
38
+ uniform vec4 uDetail[33]; // wgsl:material
39
+
40
+ bool detailed() { return uDetail[0].x > 0.0; }
41
+
42
+ /* The region a texel of the base maps is in, read exactly, held to the regions there are. */
43
+ int detailRegion(vec2 uv) {
44
+ int last = int(uDetail[0].x) - 1;
45
+ if (uDetail[0].y < 0.0) return 0;
46
+ ivec2 size = textureSize(uOrmMap, 0).xy;
47
+ ivec2 texel = clamp(ivec2(floor(fract(uv) * vec2(size))), ivec2(0), size - 1);
48
+ int region = int(texelFetch(uOrmMap, ivec3(texel, int(uDetail[0].y)), 0).r * 255.0 + 0.5);
49
+ return clamp(region, 0, last);
50
+ }
51
+
52
+ void surfaceDetail(inout vec3 n, inout float occlusion, vec2 uv) {
53
+ /* In uniform control flow, before anything that depends on the pixel's region. */
54
+ mat3 frame = tangentFrame(n, vWorldPos, uv, vTangent, vHasTangents);
55
+ vec2 dx = dFdx(uv);
56
+ vec2 dy = dFdy(uv);
57
+ int region = detailRegion(uv);
58
+ vec4 picks = uDetail[1 + region * 2];
59
+ vec4 place = uDetail[2 + region * 2];
60
+ vec2 at = uv * place.xy + place.zw;
61
+ vec2 gx = dx * place.xy;
62
+ vec2 gy = dy * place.xy;
63
+ vec3 d = textureGrad(uModelMap, vec3(at, picks.x), gx, gy).xyz * 2.0 - 1.0;
64
+ /* z rebuilt where none is stored, as the base map's is: normalMap.ts. */
65
+ d.z = d.z > 0.0 ? d.z : sqrt(max(0.0, 1.0 - dot(d.xy, d.xy)));
66
+ d = normalize(vec3(d.xy * picks.z, max(d.z, 1e-4)));
67
+ n = normalize(frame * d);
68
+ if (picks.y >= 0.0) {
69
+ float held = textureGrad(uModelMap, vec3(at, picks.y), gx, gy).r;
70
+ occlusion *= mix(1.0, held, picks.w);
71
+ }
72
+ }
73
+ `;
74
+ }
@@ -40,7 +40,8 @@ export type LitSwitch =
40
40
  | 'WORLD_UVS'
41
41
  | 'MOVING_SUN'
42
42
  | 'LAYERED'
43
- | 'LAYER_LOOKS';
43
+ | 'LAYER_LOOKS'
44
+ | 'SURFACE_DETAIL';
44
45
 
45
46
  /** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
46
47
  export function noLitSwitches(): Record<LitSwitch, boolean> {
@@ -57,6 +58,7 @@ export function noLitSwitches(): Record<LitSwitch, boolean> {
57
58
  MOVING_SUN: false,
58
59
  LAYERED: false,
59
60
  LAYER_LOOKS: false,
61
+ SURFACE_DETAIL: false,
60
62
  };
61
63
  }
62
64
 
@@ -24,6 +24,7 @@ import { overlayGlsl } from './overlay.ts';
24
24
  import { reflectionSurfaceGlsl } from './reflectionSurface.ts';
25
25
  import { worldUvGlsl } from './worldUv.ts';
26
26
  import { movingSunGlsl } from './movingSun.ts';
27
+ import { detailGlsl } from './detail.ts';
27
28
  import { layeredGlsl } from './layered.ts';
28
29
  import { MAP_READS_GLSL } from './mapReads.ts';
29
30
  import { CHANNEL_BEND, LIT_CHANNEL_ATTRIBUTE } from '../vertexChannel.ts';
@@ -539,6 +540,11 @@ export interface FlatShaderOptions {
539
540
  * \`uLayerLooks\` and all. True when absent; nothing without \`layered\`. See \`layerLooksGlsl\`.
540
541
  */
541
542
  readonly layerLooks?: boolean;
543
+ /**
544
+ * Whether it lays a material's tiled detail over its maps, \`uDetail\` and all
545
+ * (\`SurfaceMaterial.detail\`). True when absent. See \`detailGlsl\`.
546
+ */
547
+ readonly surfaceDetail?: boolean;
542
548
  /**
543
549
  * How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
544
550
  *
@@ -692,6 +698,8 @@ export function flatFrag(options: FlatShaderOptions): string {
692
698
  worldUvGlsl(options.worldUvs ?? true),
693
699
  /* After the projection, so its switch takes the next id; the normal map below calls it. */
694
700
  layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
701
+ /* After the layers, so its switch takes the next id; main calls it. See detail.ts. */
702
+ detailGlsl(options.surfaceDetail ?? true),
695
703
  /* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
696
704
  MAP_READS_GLSL,
697
705
  /* After the frame and the projection it calls, unconditional for the same reason as the
@@ -227,6 +227,8 @@ export const MAIN_GLSL = `void main() {
227
227
  if (uNormalStrength > 0.0) n = normalMapped(n, surfaceAt, frameAt, frameTangents);
228
228
  /* A draw's wrinkles, by region, over whatever the map made: overlay.ts. */
229
229
  if (SURFACE_OVERLAY) n = overlayWrinkle(n, unmapped, surfaceAt.xy);
230
+ /* The material's tiled detail over whatever the maps made, and its occlusion: detail.ts. */
231
+ if (SURFACE_DETAIL && detailed()) surfaceDetail(n, ormOcclusion, surfaceAt.xy);
230
232
 
231
233
  /*
232
234
  * Microscopic relief: the surface's own texture, as a turn of the normal rather than a change