@driftengine/core 4.13.1 → 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 (78) hide show
  1. package/CHANGELOG.json +42 -0
  2. package/README.md +1 -1
  3. package/dist/index.d.ts +4 -1
  4. package/dist/index.js +1 -0
  5. package/dist/render/backend/api.d.ts +63 -8
  6. package/dist/render/backend/webgl2/pointSkinFeedback.d.ts +23 -0
  7. package/dist/render/backend/webgl2/pointSkinFeedback.js +159 -0
  8. package/dist/render/backend/webgl2/renderer.d.ts +33 -0
  9. package/dist/render/backend/webgl2/renderer.js +88 -4
  10. package/dist/render/backend/webgpu/buffers.d.ts +10 -2
  11. package/dist/render/backend/webgpu/buffers.js +66 -16
  12. package/dist/render/backend/webgpu/clothTextures.d.ts +6 -3
  13. package/dist/render/backend/webgpu/clothTextures.js +14 -11
  14. package/dist/render/backend/webgpu/drawCommand.js +3 -3
  15. package/dist/render/backend/webgpu/gpuClothSet.js +1 -0
  16. package/dist/render/backend/webgpu/groupShelf.d.ts +31 -0
  17. package/dist/render/backend/webgpu/groupShelf.js +54 -0
  18. package/dist/render/backend/webgpu/pointSkinPass.d.ts +27 -0
  19. package/dist/render/backend/webgpu/pointSkinPass.js +104 -0
  20. package/dist/render/backend/webgpu/renderer.d.ts +40 -7
  21. package/dist/render/backend/webgpu/renderer.js +151 -48
  22. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.d.ts +1 -1
  23. package/dist/render/backend/webgpu/shaders/clothSolve.wgsl.js +7 -2
  24. package/dist/render/backend/webgpu/shaders/pointSkin.wgsl.d.ts +13 -0
  25. package/dist/render/backend/webgpu/shaders/pointSkin.wgsl.js +57 -0
  26. package/dist/render/backend/webgpu/vertexFormats.js +1 -1
  27. package/dist/render/lightBudget.d.ts +6 -0
  28. package/dist/render/lightBudget.js +6 -0
  29. package/dist/render/mesh.d.ts +19 -5
  30. package/dist/render/mesh.js +39 -8
  31. package/dist/render/pointSkin.d.ts +55 -0
  32. package/dist/render/pointSkin.js +121 -0
  33. package/dist/render/rendererHarness.js +6 -0
  34. package/dist/render/shader.d.ts +3 -1
  35. package/dist/render/shader.js +6 -1
  36. package/dist/render/shaders/flat/detail.d.ts +21 -0
  37. package/dist/render/shaders/flat/detail.js +74 -0
  38. package/dist/render/shaders/flat/glassTint.d.ts +1 -1
  39. package/dist/render/shaders/flat/glassTint.js +1 -0
  40. package/dist/render/shaders/flat/index.d.ts +5 -0
  41. package/dist/render/shaders/flat/index.js +3 -0
  42. package/dist/render/shaders/flat/main.d.ts +1 -1
  43. package/dist/render/shaders/flat/main.js +2 -0
  44. package/dist/render/shaders/generated/flat.wgsl.d.ts +144 -16
  45. package/dist/render/shaders/generated/flat.wgsl.js +183 -55
  46. package/dist/render/surfaceDetail.d.ts +79 -0
  47. package/dist/render/surfaceDetail.js +86 -0
  48. package/dist/render/surfaceTexture.d.ts +9 -0
  49. package/dist/render/vertexPacking.d.ts +16 -4
  50. package/dist/render/vertexPacking.js +10 -3
  51. package/package.json +6 -6
  52. package/src/index.ts +4 -0
  53. package/src/render/backend/api.ts +70 -7
  54. package/src/render/backend/webgl2/pointSkinFeedback.ts +173 -0
  55. package/src/render/backend/webgl2/renderer.ts +121 -4
  56. package/src/render/backend/webgpu/buffers.ts +90 -16
  57. package/src/render/backend/webgpu/clothTextures.ts +14 -11
  58. package/src/render/backend/webgpu/drawCommand.ts +3 -3
  59. package/src/render/backend/webgpu/gpuClothSet.ts +1 -0
  60. package/src/render/backend/webgpu/groupShelf.ts +74 -0
  61. package/src/render/backend/webgpu/pointSkinPass.ts +115 -0
  62. package/src/render/backend/webgpu/renderer.ts +192 -83
  63. package/src/render/backend/webgpu/shaders/clothSolve.wgsl.ts +7 -2
  64. package/src/render/backend/webgpu/shaders/pointSkin.wgsl.ts +58 -0
  65. package/src/render/backend/webgpu/vertexFormats.ts +1 -1
  66. package/src/render/lightBudget.ts +7 -0
  67. package/src/render/mesh.ts +49 -7
  68. package/src/render/pointSkin.ts +178 -0
  69. package/src/render/rendererHarness.ts +6 -0
  70. package/src/render/shader.ts +5 -0
  71. package/src/render/shaders/flat/detail.ts +74 -0
  72. package/src/render/shaders/flat/glassTint.ts +3 -1
  73. package/src/render/shaders/flat/index.ts +8 -0
  74. package/src/render/shaders/flat/main.ts +2 -0
  75. package/src/render/shaders/generated/flat.wgsl.ts +183 -55
  76. package/src/render/surfaceDetail.ts +136 -0
  77. package/src/render/surfaceTexture.ts +9 -0
  78. package/src/render/vertexPacking.ts +25 -5
@@ -92,6 +92,12 @@ export class Mesh {
92
92
  * this — two references, and only where a consumer asked.
93
93
  */
94
94
  deformable = null;
95
+ /** A dynamic mesh's position and normal buffers, for a pose written on the device; else null. */
96
+ get streams() {
97
+ return this.deformable;
98
+ }
99
+ /** The colour buffer, kept so `updateColors` can rewrite it. Null unless `dynamicColors`. */
100
+ recolourable = null;
95
101
  /** How many floats the position buffer holds, so an update of the wrong size is refused. */
96
102
  positionFloats;
97
103
  /**
@@ -191,8 +197,9 @@ export class Mesh {
191
197
  * and the default is the fast path: filling a buffer with `bufferData` in one call skips the
192
198
  * zero-fill that `bufferData(size)` then `bufferSubData` pays for, and every mesh in the
193
199
  * engine that does not need to be spread should keep skipping it.
200
+ * @param dynamicColors Colours as floats in a buffer kept for `updateColors`.
194
201
  */
195
- constructor(gl, data, dynamic = false, spread = false) {
202
+ constructor(gl, data, dynamic = false, spread = false, dynamicColors = false) {
196
203
  validateMeshData(data);
197
204
  this.spread = spread;
198
205
  this.hasUvs = data.uvs !== undefined;
@@ -212,12 +219,17 @@ export class Mesh {
212
219
  */
213
220
  const usage = dynamic ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW;
214
221
  /* How each attribute travels: sixteen-bit fixed point, or one value as a constant. */
215
- const packing = vertexPackingOf(data, dynamic);
222
+ const packing = vertexPackingOf(data, dynamic, dynamicColors);
216
223
  const positions = this.attachAttribute(gl, ATTR_POSITION, data.positions, 3, usage);
217
224
  const normals = this.attachAttribute(gl, ATTR_NORMAL, data.normals, 3, usage, packing.normals);
218
225
  if (dynamic)
219
226
  this.deformable = { positions, normals };
220
- this.attachShared(gl, ATTR_COLOR, data.colors, 3, packing.colors);
227
+ if (packing.colors === 'stream') {
228
+ this.recolourable = this.attachAttribute(gl, ATTR_COLOR, data.colors, 3, gl.DYNAMIC_DRAW);
229
+ }
230
+ else {
231
+ this.attachShared(gl, ATTR_COLOR, data.colors, 3, packing.colors);
232
+ }
221
233
  this.attachShared(gl, ATTR_EMISSIVE, data.emissive, 1, packing.emissive);
222
234
  /*
223
235
  * Absent means "not shiny", supplied as a constant rather than as a buffer of
@@ -418,9 +430,9 @@ export class Mesh {
418
430
  /**
419
431
  * Rewrite this mesh's positions, and its normals where the caller has them.
420
432
  *
421
- * **Only the two attributes that move.** Colour, emissive and the rest describe what a surface
422
- * *is* and do not change because it bent; rewriting them would be uploading unchanged bytes
423
- * every frame. A caller with genuinely changing colour wants a second mesh or a tint.
433
+ * **Only the two attributes that move.** Emissive and the rest describe what a surface *is* and
434
+ * do not change because it bent; rewriting them would be uploading unchanged bytes every frame.
435
+ * A caller whose colours change has `updateColors`, on a mesh made `dynamicColors`.
424
436
  *
425
437
  * **The bounds are recomputed**, because everything that decides whether this is on screen
426
438
  * starts from them — a cloth that blew sideways out of its original box would be culled while
@@ -445,6 +457,25 @@ export class Mesh {
445
457
  gl.bufferSubData(gl.ARRAY_BUFFER, 0, normals);
446
458
  }
447
459
  }
460
+ /**
461
+ * Rewrite this mesh's colours, three floats a vertex, into the buffer they have to themselves.
462
+ *
463
+ * Nothing else moves, the bounds included: a colour does not change where a vertex is.
464
+ */
465
+ updateColors(gl, colors) {
466
+ if (this.recolourable === null) {
467
+ throw new Error('this mesh was not created with { dynamicColors: true }, so its colours are packed or are ' +
468
+ 'one value with no buffer at all, and cannot be rewritten. Say so at `createMesh`: the ' +
469
+ 'flag gives the colours a buffer of their own.');
470
+ }
471
+ if (colors.length !== this.positionFloats) {
472
+ throw new Error(`this mesh has ${this.positionFloats / 3} vertices and the colours have ` +
473
+ `${colors.length / 3}. A mesh's vertex count is fixed at creation: the index buffer, ` +
474
+ 'the pipeline and every other attribute are sized against it.');
475
+ }
476
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.recolourable);
477
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, colors);
478
+ }
448
479
  /**
449
480
  * One attribute's buffer, as floats or in its sixteen-bit fixed point (`vertexPacking.ts`), which
450
481
  * WebGL2 reads back through a normalised `SHORT` or `UNSIGNED_SHORT`: three lanes wide where
@@ -537,8 +568,8 @@ export class Mesh {
537
568
  * the allocations. The bounds are the deliberate one — a streamer places and culls a region from
538
569
  * them before a byte of it has landed.
539
570
  */
540
- export function createMeshIncremental(gl, data, dynamic = false) {
541
- const mesh = new Mesh(gl, data, dynamic, true);
571
+ export function createMeshIncremental(gl, data, dynamic = false, dynamicColors = false) {
572
+ const mesh = new Mesh(gl, data, dynamic, true, dynamicColors);
542
573
  return { mesh, upload: mesh.uploads(gl) };
543
574
  }
544
575
  /** Texture coordinates and layers interleaved as (u, v, layer), one row a vertex. */
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A mesh posed on the device from weighted points in its joints' own spaces: the data, its checks,
3
+ * and the bounds a pose is culled by. Each backend's binding poses the vertices.
4
+ *
5
+ * **What it is for.** Some formats store a vertex as up to four points, each in the space of a
6
+ * joint, with a weight each and a normal in the space of the first: posed, the vertex is the
7
+ * weighted sum of each point moved by its joint. That is not ordinary skinning, which moves one
8
+ * bind-pose position by a blend of joints, so a rig of that kind had to be posed on the main thread
9
+ * and its positions uploaded every frame. Reported at 38% of the main thread's drawing time.
10
+ * Posed on the device into a dynamic mesh's own buffers, the main thread hands over the joints.
11
+ *
12
+ * **Into a dynamic mesh rather than in the vertex stage**, because a skinned vertex stage has no
13
+ * attribute left for four more points, and every pass that draws a mesh would need a variant that
14
+ * reads them: lit, cast, glass, motion. Posed once into the mesh's positions and normals, it is an
15
+ * ordinary dynamic mesh to every one of them, motion vectors included. What it costs is the mesh's
16
+ * two buffers, written a pose, and a pass that runs before the draws.
17
+ *
18
+ * **The bounds are a box of spheres, not the posed vertices**, which the CPU never sees. Each joint
19
+ * reaches as far as the farthest point a weighted influence holds in its space, scaled by its
20
+ * largest axis; a weighted sum of points inside those spheres lies inside their box, scaled by the
21
+ * least and greatest sums of a vertex's weights. Conservative, so it culls nothing visible; loose
22
+ * by the joints' reach where a mesh is long and thin, which culls a little less than it could.
23
+ */
24
+ import type { Bounds } from '../math/bounds.ts';
25
+ /** A point skin, one entry a vertex unless said otherwise. */
26
+ export interface PointSkinData {
27
+ /** Four joint indices a vertex, as floats, as `MeshData.joints` is. */
28
+ readonly joints: Float32Array;
29
+ /** Four weights a vertex. An influence of weight 0 is skipped, whatever its joint and point. */
30
+ readonly weights: Float32Array;
31
+ /** Four points a vertex, three floats each: influence `k`'s point in joint `k`'s own space. */
32
+ readonly points: Float32Array;
33
+ /** One normal a vertex, in the space of its first joint. */
34
+ readonly normals: Float32Array;
35
+ }
36
+ /** How far a skin reaches from each joint, and what its weights sum to: what a pose's bounds need. */
37
+ export interface PointSkinReach {
38
+ /** Joints a pose must hand over: one past the highest any vertex names and uses. */
39
+ readonly joints: number;
40
+ /** For each joint, the farthest point a weighted influence holds in its space; -1 where none. */
41
+ readonly reach: Float32Array;
42
+ /** The least and the greatest sum of one vertex's weights. */
43
+ readonly sums: readonly [number, number];
44
+ }
45
+ /** Refuse a skin whose arrays disagree with a mesh of `vertices`, or that names a joint past the rest. */
46
+ export declare function validatePointSkin(data: PointSkinData, vertices: number): void;
47
+ /** Measure a skin's reach once, at creation. */
48
+ export declare function reachOf(data: PointSkinData): PointSkinReach;
49
+ /**
50
+ * The bounds of a pose, into the mesh's own: `matrices` holds each joint's transform into the
51
+ * mesh's model space, sixteen floats, column-major. Allocates nothing.
52
+ */
53
+ export declare function poseBounds(matrices: Float32Array, skin: PointSkinReach, out: Bounds): void;
54
+ /** Refuse a pose that hands over fewer joints than the skin names. */
55
+ export declare function validatePose(matrices: Float32Array, skin: PointSkinReach): void;
@@ -0,0 +1,121 @@
1
+ import { MAX_JOINTS } from './skinPalette.js';
2
+ /** Refuse a skin whose arrays disagree with a mesh of `vertices`, or that names a joint past the rest. */
3
+ export function validatePointSkin(data, vertices) {
4
+ const fail = (what) => {
5
+ throw new Error(`point skin: ${what}`);
6
+ };
7
+ for (const [name, values, width] of [
8
+ ['joints', data.joints, 4],
9
+ ['weights', data.weights, 4],
10
+ ['points', data.points, 12],
11
+ ['normals', data.normals, 3],
12
+ ]) {
13
+ if (values.length !== vertices * width) {
14
+ fail(`${name} holds ${values.length / width} vertices and the mesh has ${vertices}`);
15
+ }
16
+ }
17
+ for (let at = 0; at < data.joints.length; at++) {
18
+ const joint = data.joints[at];
19
+ if (!(Number.isInteger(joint) && joint >= 0 && joint < MAX_JOINTS)) {
20
+ fail(`vertex ${Math.floor(at / 4)} names joint ${joint}; a joint is 0 to ${MAX_JOINTS - 1}`);
21
+ }
22
+ }
23
+ }
24
+ /** Measure a skin's reach once, at creation. */
25
+ export function reachOf(data) {
26
+ const reach = new Float32Array(MAX_JOINTS).fill(-1);
27
+ let joints = 0;
28
+ let least = Infinity;
29
+ let most = -Infinity;
30
+ const vertices = data.weights.length / 4;
31
+ for (let v = 0; v < vertices; v++) {
32
+ /* The first joint carries the normal, so it is named whatever its weight. */
33
+ joints = Math.max(joints, data.joints[v * 4] + 1);
34
+ let sum = 0;
35
+ for (let k = 0; k < 4; k++) {
36
+ const weight = data.weights[v * 4 + k];
37
+ if (weight === 0)
38
+ continue;
39
+ sum += weight;
40
+ const joint = data.joints[v * 4 + k];
41
+ joints = Math.max(joints, joint + 1);
42
+ const at = (v * 4 + k) * 3;
43
+ const x = data.points[at];
44
+ const y = data.points[at + 1];
45
+ const z = data.points[at + 2];
46
+ reach[joint] = Math.max(reach[joint], Math.sqrt(x * x + y * y + z * z));
47
+ }
48
+ least = Math.min(least, sum);
49
+ most = Math.max(most, sum);
50
+ }
51
+ return {
52
+ joints,
53
+ reach: reach.slice(0, joints),
54
+ sums: vertices === 0 ? [1, 1] : [least, most],
55
+ };
56
+ }
57
+ /**
58
+ * The bounds of a pose, into the mesh's own: `matrices` holds each joint's transform into the
59
+ * mesh's model space, sixteen floats, column-major. Allocates nothing.
60
+ */
61
+ export function poseBounds(matrices, skin, out) {
62
+ let minX = Infinity;
63
+ let minY = Infinity;
64
+ let minZ = Infinity;
65
+ let maxX = -Infinity;
66
+ let maxY = -Infinity;
67
+ let maxZ = -Infinity;
68
+ for (let j = 0; j < skin.joints; j++) {
69
+ const reach = skin.reach[j];
70
+ if (reach < 0)
71
+ continue;
72
+ const m = j * 16;
73
+ const scale = Math.sqrt(Math.max(columnLength2(matrices, m), columnLength2(matrices, m + 4), columnLength2(matrices, m + 8)));
74
+ const r = reach * scale;
75
+ const x = matrices[m + 12];
76
+ const y = matrices[m + 13];
77
+ const z = matrices[m + 14];
78
+ minX = Math.min(minX, x - r);
79
+ minY = Math.min(minY, y - r);
80
+ minZ = Math.min(minZ, z - r);
81
+ maxX = Math.max(maxX, x + r);
82
+ maxY = Math.max(maxY, y + r);
83
+ maxZ = Math.max(maxZ, z + r);
84
+ }
85
+ if (minX > maxX) {
86
+ out.min.fill(0);
87
+ out.max.fill(0);
88
+ out.centre.fill(0);
89
+ out.radius = 0;
90
+ return;
91
+ }
92
+ /* A sum of weights other than one scales the box about the origin, by the least and the most. */
93
+ const [least, most] = skin.sums;
94
+ out.min[0] = Math.min(least * minX, most * minX);
95
+ out.min[1] = Math.min(least * minY, most * minY);
96
+ out.min[2] = Math.min(least * minZ, most * minZ);
97
+ out.max[0] = Math.max(least * maxX, most * maxX);
98
+ out.max[1] = Math.max(least * maxY, most * maxY);
99
+ out.max[2] = Math.max(least * maxZ, most * maxZ);
100
+ let radius2 = 0;
101
+ for (let axis = 0; axis < 3; axis++) {
102
+ const centre = (out.min[axis] + out.max[axis]) / 2;
103
+ out.centre[axis] = centre;
104
+ const half = out.max[axis] - centre;
105
+ radius2 += half * half;
106
+ }
107
+ out.radius = Math.sqrt(radius2);
108
+ }
109
+ /** The squared length of one column's first three components. */
110
+ function columnLength2(m, at) {
111
+ const x = m[at];
112
+ const y = m[at + 1];
113
+ const z = m[at + 2];
114
+ return x * x + y * y + z * z;
115
+ }
116
+ /** Refuse a pose that hands over fewer joints than the skin names. */
117
+ export function validatePose(matrices, skin) {
118
+ if (matrices.length % 16 !== 0 || matrices.length / 16 < skin.joints) {
119
+ throw new Error(`point skin: a pose of ${matrices.length / 16} joints for a skin that names ${skin.joints}`);
120
+ }
121
+ }
@@ -77,6 +77,12 @@ export function recordingGl(options = {}) {
77
77
  ONE: 1,
78
78
  ONE_MINUS_SRC_ALPHA: 0x0303,
79
79
  TRIANGLES: 0x0004,
80
+ /* Transform feedback's, so a point skin's pose can be seen to discard, capture and undo both. */
81
+ POINTS: 0x0000,
82
+ RASTERIZER_DISCARD: 0x8c89,
83
+ TRANSFORM_FEEDBACK: 0x8e22,
84
+ TRANSFORM_FEEDBACK_BUFFER: 0x8c8e,
85
+ SEPARATE_ATTRIBS: 0x8c8d,
80
86
  /* The winding a pass names, so a test can tell which one it chose. */
81
87
  CW: 0x0900,
82
88
  CCW: 0x0901,
@@ -1,5 +1,7 @@
1
1
  /** Shader compilation with fail-fast diagnostics (init-time only, never per-frame). */
2
- export declare function compileProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string, label: string): WebGLProgram;
2
+ export declare function compileProgram(gl: WebGL2RenderingContext, vertexSource: string, fragmentSource: string, label: string,
3
+ /** Vertex outputs captured by transform feedback, one buffer each; named before the link. */
4
+ feedback?: readonly string[]): WebGLProgram;
3
5
  /**
4
6
  * A program compiled and linked without asking whether that worked, since asking waits for the
5
7
  * driver to finish: for a context offering `KHR_parallel_shader_compile`, whose
@@ -1,5 +1,7 @@
1
1
  /** Shader compilation with fail-fast diagnostics (init-time only, never per-frame). */
2
- export function compileProgram(gl, vertexSource, fragmentSource, label) {
2
+ export function compileProgram(gl, vertexSource, fragmentSource, label,
3
+ /** Vertex outputs captured by transform feedback, one buffer each; named before the link. */
4
+ feedback) {
3
5
  const vs = compileStage(gl, gl.VERTEX_SHADER, vertexSource, `${label}.vert`);
4
6
  const fs = compileStage(gl, gl.FRAGMENT_SHADER, fragmentSource, `${label}.frag`);
5
7
  const program = gl.createProgram();
@@ -7,6 +9,9 @@ export function compileProgram(gl, vertexSource, fragmentSource, label) {
7
9
  throw new Error(`[${label}] createProgram failed`);
8
10
  gl.attachShader(program, vs);
9
11
  gl.attachShader(program, fs);
12
+ if (feedback !== undefined) {
13
+ gl.transformFeedbackVaryings(program, feedback, gl.SEPARATE_ATTRIBS);
14
+ }
10
15
  gl.linkProgram(program);
11
16
  gl.deleteShader(vs);
12
17
  gl.deleteShader(fs);
@@ -0,0 +1,21 @@
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 declare function detailGlsl(on: boolean): string;
@@ -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) {
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
+ }
@@ -8,7 +8,7 @@ export declare function glassTintGlsl(glassShadows: boolean): string;
8
8
  * The lit stage's switches, by the name of the constant each is: the decision both backends make
9
9
  * about which optional code a lit program carries. See `litSwitchesGlsl`.
10
10
  */
11
- export type LitSwitch = 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT' | 'PHYSICAL_SPECULAR' | 'SURFACE_OVERLAY' | 'REFLECTION_MAPS' | 'WORLD_UVS' | 'MOVING_SUN' | 'LAYERED' | 'LAYER_LOOKS';
11
+ export type LitSwitch = 'GLASS_SHADOWS' | 'CLUSTERED_LIGHTS' | 'LIGHT_FIXTURES' | 'SURFACE_EFFECTS' | 'DRIFT_LIGHT' | 'PHYSICAL_SPECULAR' | 'SURFACE_OVERLAY' | 'REFLECTION_MAPS' | 'WORLD_UVS' | 'MOVING_SUN' | 'LAYERED' | 'LAYER_LOOKS' | 'SURFACE_DETAIL';
12
12
  /** Every switch off: what a renderer starts from before its profile and its content say otherwise. */
13
13
  export declare function noLitSwitches(): Record<LitSwitch, boolean>;
14
14
  /** Which of the lit stage's optional features a build reads. See `litSwitchesGlsl`. */
@@ -38,6 +38,7 @@ export function noLitSwitches() {
38
38
  MOVING_SUN: false,
39
39
  LAYERED: false,
40
40
  LAYER_LOOKS: false,
41
+ SURFACE_DETAIL: false,
41
42
  };
42
43
  }
43
44
  /**
@@ -145,6 +145,11 @@ export interface FlatShaderOptions {
145
145
  * \`uLayerLooks\` and all. True when absent; nothing without \`layered\`. See \`layerLooksGlsl\`.
146
146
  */
147
147
  readonly layerLooks?: boolean;
148
+ /**
149
+ * Whether it lays a material's tiled detail over its maps, \`uDetail\` and all
150
+ * (\`SurfaceMaterial.detail\`). True when absent. See \`detailGlsl\`.
151
+ */
152
+ readonly surfaceDetail?: boolean;
148
153
  /**
149
154
  * How many point lights this build declares room for. `MAX_POINT_LIGHTS` when absent.
150
155
  *
@@ -24,6 +24,7 @@ import { overlayGlsl } from './overlay.js';
24
24
  import { reflectionSurfaceGlsl } from './reflectionSurface.js';
25
25
  import { worldUvGlsl } from './worldUv.js';
26
26
  import { movingSunGlsl } from './movingSun.js';
27
+ import { detailGlsl } from './detail.js';
27
28
  import { layeredGlsl } from './layered.js';
28
29
  import { MAP_READS_GLSL } from './mapReads.js';
29
30
  import { CHANNEL_BEND, LIT_CHANNEL_ATTRIBUTE } from '../vertexChannel.js';
@@ -514,6 +515,8 @@ export function flatFrag(options) {
514
515
  worldUvGlsl(options.worldUvs ?? true),
515
516
  /* After the projection, so its switch takes the next id; the normal map below calls it. */
516
517
  layeredGlsl(options.layered ?? true, options.layerLooks ?? true),
518
+ /* After the layers, so its switch takes the next id; main calls it. See detail.ts. */
519
+ detailGlsl(options.surfaceDetail ?? true),
517
520
  /* Where each map is read, after both of the ways it can be read otherwise. See mapReads.ts. */
518
521
  MAP_READS_GLSL,
519
522
  /* After the frame and the projection it calls, unconditional for the same reason as the