reze-engine 0.22.1 → 0.23.1

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.
@@ -0,0 +1,526 @@
1
+ // Debug overlay drawing every rigidbody as a wireframe + semitransparent solid
2
+ // primitive (sphere / box / capsule), color-coded by type:
3
+ // yellow = static (FollowBone)
4
+ // cyan = kinematic
5
+ // red = dynamic
6
+ //
7
+ // Rendered in its own swapchain pass AFTER composite (see Engine.renderPhysicsDebug),
8
+ // so it sits cleanly on top of the final tonemapped image. No depth, no MSAA,
9
+ // no MRT, no interaction with the HDR alpha gate — bodies are always fully
10
+ // visible regardless of camera angle, model occlusion, or stacking. Reads body
11
+ // transforms straight from RezePhysics' SoA store — zero per-frame allocations.
12
+ import { Mat4 } from "./math";
13
+ import { RigidbodyShape, RigidbodyType } from "./physics";
14
+ import { PHYSICS_DEBUG_SHADER_WGSL } from "./shaders/passes/physics-debug";
15
+ const STRIDE_BYTES = 96; // 4 mat4 cols + size+pad + color = 6 vec4
16
+ const STRIDE_FLOATS = STRIDE_BYTES / 4;
17
+ // Per-instance color.alpha is the WIREFRAME alpha (1.0 for crisp edges). Solid
18
+ // pipelines override the SOLID_ALPHA shader constant to ~0.20 so the fill stays
19
+ // gentle where many bodies overlap.
20
+ // Hues chosen so wire+solid both read against the typical pink/grey reze studio
21
+ // scene background. Red [1, 0.3, 0.3] sat too close to the pink bg's hue —
22
+ // solid fill (α≈0.2) blended into the bg and the wire didn't separate from it.
23
+ // Orange-red shifts ~30° on the hue wheel, giving real chroma against pink.
24
+ const COLOR_STATIC = [1.0, 0.85, 0.15, 1.0]; // yellow
25
+ const COLOR_KINEMATIC = [0.25, 0.7, 1.0, 1.0]; // cyan-blue
26
+ const COLOR_DYNAMIC = [1.0, 0.35, 0.0, 1.0]; // orange-red
27
+ const SOLID_ALPHA = 0.2;
28
+ export class PhysicsDebugRenderer {
29
+ constructor(device, cameraUniformBuffer, presentationFormat) {
30
+ this.device = device;
31
+ const wireSphere = buildSphereWireGeometry();
32
+ const wireBox = buildBoxWireGeometry();
33
+ const wireCapsule = buildCapsuleWireGeometry();
34
+ this.wireSphereCount = wireSphere.length / 4;
35
+ this.wireBoxCount = wireBox.length / 4;
36
+ this.wireCapsuleCount = wireCapsule.length / 4;
37
+ const solidSphere = buildSphereSolidGeometry();
38
+ const solidBox = buildBoxSolidGeometry();
39
+ const solidCapsule = buildCapsuleSolidGeometry();
40
+ this.solidSphereCount = solidSphere.length / 4;
41
+ this.solidBoxCount = solidBox.length / 4;
42
+ this.solidCapsuleCount = solidCapsule.length / 4;
43
+ const upload = (label, data) => {
44
+ const buf = device.createBuffer({
45
+ label,
46
+ size: data.byteLength,
47
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
48
+ });
49
+ device.queue.writeBuffer(buf, 0, data.buffer, data.byteOffset, data.byteLength);
50
+ return buf;
51
+ };
52
+ this.wireSphereBuffer = upload("physics-debug wire sphere", wireSphere);
53
+ this.wireBoxBuffer = upload("physics-debug wire box", wireBox);
54
+ this.wireCapsuleBuffer = upload("physics-debug wire capsule", wireCapsule);
55
+ this.solidSphereBuffer = upload("physics-debug solid sphere", solidSphere);
56
+ this.solidBoxBuffer = upload("physics-debug solid box", solidBox);
57
+ this.solidCapsuleBuffer = upload("physics-debug solid capsule", solidCapsule);
58
+ this.instanceCapacity = 512;
59
+ this.instanceData = new Float32Array(this.instanceCapacity * STRIDE_FLOATS);
60
+ this.instanceBuffer = device.createBuffer({
61
+ label: "physics-debug instances",
62
+ size: this.instanceCapacity * STRIDE_BYTES,
63
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
64
+ });
65
+ const bgl = device.createBindGroupLayout({
66
+ label: "physics-debug bgl",
67
+ entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: "uniform" } }],
68
+ });
69
+ this.bindGroup = device.createBindGroup({
70
+ label: "physics-debug bg",
71
+ layout: bgl,
72
+ entries: [{ binding: 0, resource: { buffer: cameraUniformBuffer } }],
73
+ });
74
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bgl] });
75
+ const shader = device.createShaderModule({
76
+ label: "physics-debug shader",
77
+ code: PHYSICS_DEBUG_SHADER_WGSL,
78
+ });
79
+ const vertexBuffers = [
80
+ {
81
+ arrayStride: 16,
82
+ attributes: [{ shaderLocation: 0, offset: 0, format: "float32x4" }],
83
+ },
84
+ {
85
+ arrayStride: STRIDE_BYTES,
86
+ stepMode: "instance",
87
+ attributes: [
88
+ { shaderLocation: 1, offset: 0, format: "float32x4" },
89
+ { shaderLocation: 2, offset: 16, format: "float32x4" },
90
+ { shaderLocation: 3, offset: 32, format: "float32x4" },
91
+ { shaderLocation: 4, offset: 48, format: "float32x4" },
92
+ { shaderLocation: 5, offset: 64, format: "float32x4" },
93
+ { shaderLocation: 6, offset: 80, format: "float32x4" },
94
+ ],
95
+ },
96
+ ];
97
+ const targets = [
98
+ {
99
+ format: presentationFormat,
100
+ blend: {
101
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
102
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
103
+ },
104
+ },
105
+ ];
106
+ const buildPipeline = (label, kind, topology, solidAlpha) => device.createRenderPipeline({
107
+ label,
108
+ layout: pipelineLayout,
109
+ vertex: {
110
+ module: shader,
111
+ entryPoint: "vsMain",
112
+ buffers: vertexBuffers,
113
+ constants: { SHAPE_KIND: kind },
114
+ },
115
+ fragment: {
116
+ module: shader,
117
+ entryPoint: "fsMain",
118
+ targets,
119
+ constants: { SOLID_ALPHA: solidAlpha },
120
+ },
121
+ primitive: { topology, cullMode: "none" },
122
+ // No depth attachment, single multisample — pass renders straight to
123
+ // the swapchain after composite, so the overlay sits on top of the
124
+ // tonemapped scene without interacting with depth/stencil/MSAA.
125
+ multisample: { count: 1 },
126
+ });
127
+ this.wirePipelineSphere = buildPipeline("physics-debug wire sphere", 0, "line-list", 1.0);
128
+ this.wirePipelineBox = buildPipeline("physics-debug wire box", 1, "line-list", 1.0);
129
+ this.wirePipelineCapsule = buildPipeline("physics-debug wire capsule", 2, "line-list", 1.0);
130
+ this.solidPipelineSphere = buildPipeline("physics-debug solid sphere", 0, "triangle-list", SOLID_ALPHA);
131
+ this.solidPipelineBox = buildPipeline("physics-debug solid box", 1, "triangle-list", SOLID_ALPHA);
132
+ this.solidPipelineCapsule = buildPipeline("physics-debug solid capsule", 2, "triangle-list", SOLID_ALPHA);
133
+ }
134
+ render(pass, physics) {
135
+ const rigidbodies = physics.getRigidbodies();
136
+ const N = rigidbodies.length;
137
+ if (N === 0)
138
+ return;
139
+ const store = physics.getStore();
140
+ const positions = store.positions;
141
+ const orientations = store.orientations;
142
+ if (N > this.instanceCapacity) {
143
+ this.instanceCapacity = Math.max(N, this.instanceCapacity * 2);
144
+ this.instanceData = new Float32Array(this.instanceCapacity * STRIDE_FLOATS);
145
+ this.instanceBuffer.destroy();
146
+ this.instanceBuffer = this.device.createBuffer({
147
+ label: "physics-debug instances",
148
+ size: this.instanceCapacity * STRIDE_BYTES,
149
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
150
+ });
151
+ }
152
+ // Two passes: count per-shape, then fill packed by shape so we can issue
153
+ // 3 instanced draws with vertex-buffer offsets.
154
+ let sphereCount = 0;
155
+ let boxCount = 0;
156
+ let capsuleCount = 0;
157
+ for (let i = 0; i < N; i++) {
158
+ const s = rigidbodies[i].shape;
159
+ if (s === RigidbodyShape.Sphere)
160
+ sphereCount++;
161
+ else if (s === RigidbodyShape.Box)
162
+ boxCount++;
163
+ else
164
+ capsuleCount++;
165
+ }
166
+ const sphereOffset = 0;
167
+ const boxOffset = sphereCount;
168
+ const capsuleOffset = sphereCount + boxCount;
169
+ const data = this.instanceData;
170
+ let sIdx = sphereOffset;
171
+ let bIdx = boxOffset;
172
+ let cIdx = capsuleOffset;
173
+ for (let i = 0; i < N; i++) {
174
+ const rb = rigidbodies[i];
175
+ const i3 = i * 3;
176
+ const i4 = i * 4;
177
+ let slot;
178
+ if (rb.shape === RigidbodyShape.Sphere)
179
+ slot = sIdx++;
180
+ else if (rb.shape === RigidbodyShape.Box)
181
+ slot = bIdx++;
182
+ else
183
+ slot = cIdx++;
184
+ const dst = slot * STRIDE_FLOATS;
185
+ // Model matrix: rotation from quat into [dst..dst+15], then translation.
186
+ Mat4.fromQuatInto(orientations[i4 + 0], orientations[i4 + 1], orientations[i4 + 2], orientations[i4 + 3], data, dst);
187
+ data[dst + 12] = positions[i3 + 0];
188
+ data[dst + 13] = positions[i3 + 1];
189
+ data[dst + 14] = positions[i3 + 2];
190
+ // size + pad
191
+ data[dst + 16] = rb.size.x;
192
+ data[dst + 17] = rb.size.y;
193
+ data[dst + 18] = rb.size.z;
194
+ data[dst + 19] = 0;
195
+ const c = rb.type === RigidbodyType.Static
196
+ ? COLOR_STATIC
197
+ : rb.type === RigidbodyType.Kinematic
198
+ ? COLOR_KINEMATIC
199
+ : COLOR_DYNAMIC;
200
+ data[dst + 20] = c[0];
201
+ data[dst + 21] = c[1];
202
+ data[dst + 22] = c[2];
203
+ data[dst + 23] = c[3];
204
+ }
205
+ this.device.queue.writeBuffer(this.instanceBuffer, 0, data.buffer, data.byteOffset, N * STRIDE_BYTES);
206
+ pass.setBindGroup(0, this.bindGroup);
207
+ // Solid fills first (gentle ~20% alpha for shape ID), wireframe edges on top
208
+ // (95% alpha for crisp silhouette).
209
+ if (sphereCount > 0) {
210
+ const off = sphereOffset * STRIDE_BYTES;
211
+ const size = sphereCount * STRIDE_BYTES;
212
+ pass.setPipeline(this.solidPipelineSphere);
213
+ pass.setVertexBuffer(0, this.solidSphereBuffer);
214
+ pass.setVertexBuffer(1, this.instanceBuffer, off, size);
215
+ pass.draw(this.solidSphereCount, sphereCount);
216
+ pass.setPipeline(this.wirePipelineSphere);
217
+ pass.setVertexBuffer(0, this.wireSphereBuffer);
218
+ pass.setVertexBuffer(1, this.instanceBuffer, off, size);
219
+ pass.draw(this.wireSphereCount, sphereCount);
220
+ }
221
+ if (boxCount > 0) {
222
+ const off = boxOffset * STRIDE_BYTES;
223
+ const size = boxCount * STRIDE_BYTES;
224
+ pass.setPipeline(this.solidPipelineBox);
225
+ pass.setVertexBuffer(0, this.solidBoxBuffer);
226
+ pass.setVertexBuffer(1, this.instanceBuffer, off, size);
227
+ pass.draw(this.solidBoxCount, boxCount);
228
+ pass.setPipeline(this.wirePipelineBox);
229
+ pass.setVertexBuffer(0, this.wireBoxBuffer);
230
+ pass.setVertexBuffer(1, this.instanceBuffer, off, size);
231
+ pass.draw(this.wireBoxCount, boxCount);
232
+ }
233
+ if (capsuleCount > 0) {
234
+ const off = capsuleOffset * STRIDE_BYTES;
235
+ const size = capsuleCount * STRIDE_BYTES;
236
+ pass.setPipeline(this.solidPipelineCapsule);
237
+ pass.setVertexBuffer(0, this.solidCapsuleBuffer);
238
+ pass.setVertexBuffer(1, this.instanceBuffer, off, size);
239
+ pass.draw(this.solidCapsuleCount, capsuleCount);
240
+ pass.setPipeline(this.wirePipelineCapsule);
241
+ pass.setVertexBuffer(0, this.wireCapsuleBuffer);
242
+ pass.setVertexBuffer(1, this.instanceBuffer, off, size);
243
+ pass.draw(this.wireCapsuleCount, capsuleCount);
244
+ }
245
+ }
246
+ destroy() {
247
+ this.wireSphereBuffer.destroy();
248
+ this.wireBoxBuffer.destroy();
249
+ this.wireCapsuleBuffer.destroy();
250
+ this.solidSphereBuffer.destroy();
251
+ this.solidBoxBuffer.destroy();
252
+ this.solidCapsuleBuffer.destroy();
253
+ this.instanceBuffer.destroy();
254
+ }
255
+ }
256
+ // ── Geometry builders ─────────────────────────────────────────────────────────
257
+ // Each vertex is vec4(unitPos.xyz, axialAnchor) packed as 4 floats.
258
+ function buildSphereWireGeometry() {
259
+ const segs = 32;
260
+ const out = new Float32Array(3 * segs * 2 * 4); // 3 great circles
261
+ let p = 0;
262
+ for (let plane = 0; plane < 3; plane++) {
263
+ for (let i = 0; i < segs; i++) {
264
+ const a0 = (i / segs) * Math.PI * 2;
265
+ const a1 = ((i + 1) / segs) * Math.PI * 2;
266
+ const c0 = Math.cos(a0), s0 = Math.sin(a0);
267
+ const c1 = Math.cos(a1), s1 = Math.sin(a1);
268
+ let p0x = 0, p0y = 0, p0z = 0;
269
+ let p1x = 0, p1y = 0, p1z = 0;
270
+ if (plane === 0) {
271
+ p0x = c0;
272
+ p0y = s0;
273
+ p1x = c1;
274
+ p1y = s1;
275
+ }
276
+ else if (plane === 1) {
277
+ p0x = c0;
278
+ p0z = s0;
279
+ p1x = c1;
280
+ p1z = s1;
281
+ }
282
+ else {
283
+ p0y = c0;
284
+ p0z = s0;
285
+ p1y = c1;
286
+ p1z = s1;
287
+ }
288
+ out[p++] = p0x;
289
+ out[p++] = p0y;
290
+ out[p++] = p0z;
291
+ out[p++] = 0;
292
+ out[p++] = p1x;
293
+ out[p++] = p1y;
294
+ out[p++] = p1z;
295
+ out[p++] = 0;
296
+ }
297
+ }
298
+ return out;
299
+ }
300
+ function buildBoxWireGeometry() {
301
+ const edges = [
302
+ // 4 along X
303
+ [-1, -1, -1, +1, -1, -1], [-1, -1, +1, +1, -1, +1],
304
+ [-1, +1, -1, +1, +1, -1], [-1, +1, +1, +1, +1, +1],
305
+ // 4 along Y
306
+ [-1, -1, -1, -1, +1, -1], [+1, -1, -1, +1, +1, -1],
307
+ [-1, -1, +1, -1, +1, +1], [+1, -1, +1, +1, +1, +1],
308
+ // 4 along Z
309
+ [-1, -1, -1, -1, -1, +1], [+1, -1, -1, +1, -1, +1],
310
+ [-1, +1, -1, -1, +1, +1], [+1, +1, -1, +1, +1, +1],
311
+ ];
312
+ const out = new Float32Array(edges.length * 2 * 4);
313
+ let p = 0;
314
+ for (const e of edges) {
315
+ out[p++] = e[0];
316
+ out[p++] = e[1];
317
+ out[p++] = e[2];
318
+ out[p++] = 0;
319
+ out[p++] = e[3];
320
+ out[p++] = e[4];
321
+ out[p++] = e[5];
322
+ out[p++] = 0;
323
+ }
324
+ return out;
325
+ }
326
+ function buildCapsuleWireGeometry() {
327
+ const ringSegs = 32;
328
+ const arcSegs = 16;
329
+ // 2 cap rings + 4 hemisphere arcs + 4 cylinder verticals
330
+ const lineCount = ringSegs * 2 + arcSegs * 4 + 4;
331
+ const out = new Float32Array(lineCount * 2 * 4);
332
+ let p = 0;
333
+ const push = (x, y, z, axial) => {
334
+ out[p++] = x;
335
+ out[p++] = y;
336
+ out[p++] = z;
337
+ out[p++] = axial;
338
+ };
339
+ // Top ring (axial=+1) in XZ plane
340
+ for (let i = 0; i < ringSegs; i++) {
341
+ const a0 = (i / ringSegs) * Math.PI * 2;
342
+ const a1 = ((i + 1) / ringSegs) * Math.PI * 2;
343
+ push(Math.cos(a0), 0, Math.sin(a0), +1);
344
+ push(Math.cos(a1), 0, Math.sin(a1), +1);
345
+ }
346
+ // Bottom ring (axial=-1)
347
+ for (let i = 0; i < ringSegs; i++) {
348
+ const a0 = (i / ringSegs) * Math.PI * 2;
349
+ const a1 = ((i + 1) / ringSegs) * Math.PI * 2;
350
+ push(Math.cos(a0), 0, Math.sin(a0), -1);
351
+ push(Math.cos(a1), 0, Math.sin(a1), -1);
352
+ }
353
+ // Top cap arcs in XY and YZ planes (θ ∈ [0, π], pos = (cosθ, sinθ, 0) etc.)
354
+ for (let i = 0; i < arcSegs; i++) {
355
+ const t0 = (i / arcSegs) * Math.PI;
356
+ const t1 = ((i + 1) / arcSegs) * Math.PI;
357
+ push(Math.cos(t0), Math.sin(t0), 0, +1);
358
+ push(Math.cos(t1), Math.sin(t1), 0, +1);
359
+ }
360
+ for (let i = 0; i < arcSegs; i++) {
361
+ const t0 = (i / arcSegs) * Math.PI;
362
+ const t1 = ((i + 1) / arcSegs) * Math.PI;
363
+ push(0, Math.sin(t0), Math.cos(t0), +1);
364
+ push(0, Math.sin(t1), Math.cos(t1), +1);
365
+ }
366
+ // Bottom cap arcs
367
+ for (let i = 0; i < arcSegs; i++) {
368
+ const t0 = (i / arcSegs) * Math.PI;
369
+ const t1 = ((i + 1) / arcSegs) * Math.PI;
370
+ push(Math.cos(t0), -Math.sin(t0), 0, -1);
371
+ push(Math.cos(t1), -Math.sin(t1), 0, -1);
372
+ }
373
+ for (let i = 0; i < arcSegs; i++) {
374
+ const t0 = (i / arcSegs) * Math.PI;
375
+ const t1 = ((i + 1) / arcSegs) * Math.PI;
376
+ push(0, -Math.sin(t0), Math.cos(t0), -1);
377
+ push(0, -Math.sin(t1), Math.cos(t1), -1);
378
+ }
379
+ // 4 cylinder verticals: bottom rim (axial=-1) → top rim (+1) at fixed θ
380
+ push(+1, 0, 0, -1);
381
+ push(+1, 0, 0, +1);
382
+ push(-1, 0, 0, -1);
383
+ push(-1, 0, 0, +1);
384
+ push(0, 0, +1, -1);
385
+ push(0, 0, +1, +1);
386
+ push(0, 0, -1, -1);
387
+ push(0, 0, -1, +1);
388
+ return out;
389
+ }
390
+ // ── Solid (triangle-list) geometry builders ─────────────────────────────────
391
+ // Each vertex is vec4(unitPos.xyz, axialAnchor) — same layout as wireframe.
392
+ // UV sphere — stacks × slices quads, each split into 2 triangles. Polar quads
393
+ // degenerate to triangles, which the GPU silently drops.
394
+ function buildSphereSolidGeometry() {
395
+ const stacks = 12;
396
+ const slices = 18;
397
+ const out = new Float32Array(stacks * slices * 6 * 4);
398
+ let p = 0;
399
+ const vert = (phi, theta) => {
400
+ out[p++] = Math.cos(phi) * Math.cos(theta);
401
+ out[p++] = Math.sin(phi);
402
+ out[p++] = Math.cos(phi) * Math.sin(theta);
403
+ out[p++] = 0;
404
+ };
405
+ for (let s = 0; s < stacks; s++) {
406
+ const phi0 = -Math.PI / 2 + (s / stacks) * Math.PI;
407
+ const phi1 = -Math.PI / 2 + ((s + 1) / stacks) * Math.PI;
408
+ for (let l = 0; l < slices; l++) {
409
+ const th0 = (l / slices) * Math.PI * 2;
410
+ const th1 = ((l + 1) / slices) * Math.PI * 2;
411
+ vert(phi0, th0);
412
+ vert(phi1, th0);
413
+ vert(phi1, th1);
414
+ vert(phi0, th0);
415
+ vert(phi1, th1);
416
+ vert(phi0, th1);
417
+ }
418
+ }
419
+ return out;
420
+ }
421
+ // 6 cube faces × 2 triangles × 3 verts = 36 verts.
422
+ function buildBoxSolidGeometry() {
423
+ const faces = [
424
+ // +X face
425
+ [+1, -1, -1, +1, +1, -1, +1, +1, +1, +1, -1, +1],
426
+ // -X face
427
+ [-1, -1, -1, -1, -1, +1, -1, +1, +1, -1, +1, -1],
428
+ // +Y face
429
+ [-1, +1, -1, -1, +1, +1, +1, +1, +1, +1, +1, -1],
430
+ // -Y face
431
+ [-1, -1, -1, +1, -1, -1, +1, -1, +1, -1, -1, +1],
432
+ // +Z face
433
+ [-1, -1, +1, +1, -1, +1, +1, +1, +1, -1, +1, +1],
434
+ // -Z face
435
+ [-1, -1, -1, -1, +1, -1, +1, +1, -1, +1, -1, -1],
436
+ ];
437
+ const out = new Float32Array(faces.length * 6 * 4);
438
+ let p = 0;
439
+ const v = (x, y, z) => {
440
+ out[p++] = x;
441
+ out[p++] = y;
442
+ out[p++] = z;
443
+ out[p++] = 0;
444
+ };
445
+ for (const f of faces) {
446
+ // f = [a, b, c, d] as 4 corners; emit tris (a,b,c) (a,c,d)
447
+ v(f[0], f[1], f[2]);
448
+ v(f[3], f[4], f[5]);
449
+ v(f[6], f[7], f[8]);
450
+ v(f[0], f[1], f[2]);
451
+ v(f[6], f[7], f[8]);
452
+ v(f[9], f[10], f[11]);
453
+ }
454
+ return out;
455
+ }
456
+ // Capsule: two hemispheres at axial=±1 + cylinder side connecting their equators.
457
+ // Vertex shader does: world.y = unit.y * radius + halfHeight * axial.
458
+ function buildCapsuleSolidGeometry() {
459
+ const slices = 18;
460
+ const capStacks = 6;
461
+ const cylTris = slices * 2; // 1 stack of quads → 2 tris/slice
462
+ const capTris = capStacks * slices * 2;
463
+ const totalVerts = (cylTris + capTris * 2) * 3;
464
+ const out = new Float32Array(totalVerts * 4);
465
+ let p = 0;
466
+ const v = (x, y, z, axial) => {
467
+ out[p++] = x;
468
+ out[p++] = y;
469
+ out[p++] = z;
470
+ out[p++] = axial;
471
+ };
472
+ // Cylinder side: two rings at axial=±1, both at unit y=0.
473
+ for (let l = 0; l < slices; l++) {
474
+ const th0 = (l / slices) * Math.PI * 2;
475
+ const th1 = ((l + 1) / slices) * Math.PI * 2;
476
+ const c0 = Math.cos(th0), s0 = Math.sin(th0);
477
+ const c1 = Math.cos(th1), s1 = Math.sin(th1);
478
+ // bottom-left, top-left, top-right
479
+ v(c0, 0, s0, -1);
480
+ v(c0, 0, s0, +1);
481
+ v(c1, 0, s1, +1);
482
+ // bottom-left, top-right, bottom-right
483
+ v(c0, 0, s0, -1);
484
+ v(c1, 0, s1, +1);
485
+ v(c1, 0, s1, -1);
486
+ }
487
+ // Top hemisphere (axial=+1): φ ∈ [0, π/2], pos = (cosφ cosθ, sinφ, cosφ sinθ).
488
+ for (let s = 0; s < capStacks; s++) {
489
+ const ph0 = (s / capStacks) * (Math.PI / 2);
490
+ const ph1 = ((s + 1) / capStacks) * (Math.PI / 2);
491
+ for (let l = 0; l < slices; l++) {
492
+ const th0 = (l / slices) * Math.PI * 2;
493
+ const th1 = ((l + 1) / slices) * Math.PI * 2;
494
+ const a = (ph, th) => [
495
+ Math.cos(ph) * Math.cos(th), Math.sin(ph), Math.cos(ph) * Math.sin(th),
496
+ ];
497
+ const p00 = a(ph0, th0), p10 = a(ph1, th0), p11 = a(ph1, th1), p01 = a(ph0, th1);
498
+ v(p00[0], p00[1], p00[2], +1);
499
+ v(p10[0], p10[1], p10[2], +1);
500
+ v(p11[0], p11[1], p11[2], +1);
501
+ v(p00[0], p00[1], p00[2], +1);
502
+ v(p11[0], p11[1], p11[2], +1);
503
+ v(p01[0], p01[1], p01[2], +1);
504
+ }
505
+ }
506
+ // Bottom hemisphere (axial=-1): same but y = -sinφ.
507
+ for (let s = 0; s < capStacks; s++) {
508
+ const ph0 = (s / capStacks) * (Math.PI / 2);
509
+ const ph1 = ((s + 1) / capStacks) * (Math.PI / 2);
510
+ for (let l = 0; l < slices; l++) {
511
+ const th0 = (l / slices) * Math.PI * 2;
512
+ const th1 = ((l + 1) / slices) * Math.PI * 2;
513
+ const a = (ph, th) => [
514
+ Math.cos(ph) * Math.cos(th), -Math.sin(ph), Math.cos(ph) * Math.sin(th),
515
+ ];
516
+ const p00 = a(ph0, th0), p10 = a(ph1, th0), p11 = a(ph1, th1), p01 = a(ph0, th1);
517
+ v(p00[0], p00[1], p00[2], -1);
518
+ v(p11[0], p11[1], p11[2], -1);
519
+ v(p10[0], p10[1], p10[2], -1);
520
+ v(p00[0], p00[1], p00[2], -1);
521
+ v(p01[0], p01[1], p01[2], -1);
522
+ v(p11[0], p11[1], p11[2], -1);
523
+ }
524
+ }
525
+ return out;
526
+ }
@@ -1,2 +1,2 @@
1
- export declare const COMPOSITE_SHADER_WGSL = "\n// Pipeline-override constant: the engine creates two composite pipelines, one\n// with APPLY_GAMMA=false (gamma=1 fast path) and one with APPLY_GAMMA=true.\n// The 'if (APPLY_GAMMA)' below is resolved at pipeline-compile time \u2014 the\n// dead branch is dropped by the shader compiler (no runtime branch, no pow\n// invocation on Safari's Metal backend in the common case).\noverride APPLY_GAMMA: bool = true;\n\n@group(0) @binding(0) var hdrTex: texture_2d<f32>;\n@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)\n@group(0) @binding(2) var bloomSamp: sampler;\n@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;\n// Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).\n// .g = accumulated canvas alpha (what hdr.a carried before the HDR format\n// became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then\n// re-premultiply the tonemapped color for output so the premultiplied canvas\n// alphaMode composites the WebGPU surface over the page background correctly.\n@group(0) @binding(4) var maskTex: texture_2d<f32>;\n// Filmic tone curve baked to a WIDTH\u00D71 r16float LUT (bakeFilmicLut on the CPU side).\n// Domain: log2(linear) mapped to [0,13]. Replaces the old array<f32,14> that was indexed\n// by a runtime u32 (a Metal-backend smell \u2014 dynamic local-array indexing lowers to a\n// per-invocation copy/switch) and interpolated piecewise-linearly (C0, so segment slope\n// discontinuities showed as Mach bands in smooth skin/shadow gradients). The LUT is a\n// monotone-cubic (Fritsch\u2013Carlson) fit through the same 14 anchors \u2014 same values, C1\n// continuity kills the banding \u2014 sampled with hardware linear filtering.\n@group(0) @binding(5) var filmicLut: texture_2d<f32>;\n// viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)\n// invGamma = 1/gamma precomputed on CPU \u2014 avoids a per-pixel divide.\n\n// Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).\nconst FILMIC_LUT_W: f32 = 256.0;\n\nfn filmic(x: f32) -> f32 {\n // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender\n // look_medium-high-contrast.spi1d): linear 0.18 \u2192 ~0.395, linear 1.0 \u2192 ~0.83.\n // NOTE: version-pinned to Blender 3.6 \u2014 4.x defaults to AgX, not Filmic.\n let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);\n // Map t\u2208[0,13] to the texel-center of baked sample j = t\u00B7(W-1)/13.\n let u = (t * (FILMIC_LUT_W - 1.0) / 13.0 + 0.5) / FILMIC_LUT_W;\n // textureSampleLevel (explicit LOD, no derivatives) is legal in non-uniform flow.\n return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;\n}\n\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {\n let coord = vec2<i32>(fragCoord.xy);\n let hdr = textureLoad(hdrTex, coord, 0);\n let alpha = textureLoad(maskTex, coord, 0).g;\n let a = max(alpha, 1e-6);\n let straight = hdr.rgb / a;\n let fullSz = vec2f(textureDimensions(hdrTex));\n // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.\n // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).\n let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));\n let tint = viewU[1].xyz;\n let intensity = viewU[1].w;\n let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;\n let combined = straight + bloom;\n let exposed = combined * exp2(viewU[0].x);\n let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));\n var disp = max(tm, vec3f(0.0));\n if (APPLY_GAMMA) {\n disp = pow(disp, vec3f(viewU[0].y));\n }\n return vec4f(disp * alpha, alpha);\n}\n";
1
+ export declare const COMPOSITE_SHADER_WGSL = "\n// Pipeline-override constant: the engine creates two composite pipelines, one\n// with APPLY_GAMMA=false (gamma=1 fast path) and one with APPLY_GAMMA=true.\n// The 'if (APPLY_GAMMA)' below is resolved at pipeline-compile time \u2014 the\n// dead branch is dropped by the shader compiler (no runtime branch, no pow\n// invocation on Safari's Metal backend in the common case).\noverride APPLY_GAMMA: bool = true;\n\n@group(0) @binding(0) var hdrTex: texture_2d<f32>;\n@group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)\n@group(0) @binding(2) var bloomSamp: sampler;\n@group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 6>;\n// Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).\n// .g = accumulated canvas alpha (what hdr.a carried before the HDR format\n// became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then\n// re-premultiply the tonemapped color for output so the premultiplied canvas\n// alphaMode composites the WebGPU surface over the page background correctly.\n@group(0) @binding(4) var maskTex: texture_2d<f32>;\n// Filmic tone curve baked to a WIDTH\u00D71 r16float LUT (bakeFilmicLut on the CPU side).\n// Domain: log2(linear) mapped to [0,13]. Replaces the old array<f32,14> that was indexed\n// by a runtime u32 (a Metal-backend smell \u2014 dynamic local-array indexing lowers to a\n// per-invocation copy/switch) and interpolated piecewise-linearly (C0, so segment slope\n// discontinuities showed as Mach bands in smooth skin/shadow gradients). The LUT is a\n// monotone-cubic (Fritsch\u2013Carlson) fit through the same 14 anchors \u2014 same values, C1\n// continuity kills the banding \u2014 sampled with hardware linear filtering.\n@group(0) @binding(5) var filmicLut: texture_2d<f32>;\n// viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)\n// viewU[2] = (background.rgb, mode) \u2014 display-space sRGB, composited UNDER the\n// scene post-tonemap. mode: 0 transparent (DOM shows), 1 solid color,\n// 2 = 360 equirect skybox sampled by view ray.\n// viewU[3] = (camera right, tanHalfFov\u00B7aspect); viewU[4] = (camera up, tanHalfFov);\n// viewU[5] = (camera forward, _) \u2014 refreshed per frame while the skybox is active.\n// invGamma = 1/gamma precomputed on CPU \u2014 avoids a per-pixel divide.\n@group(0) @binding(6) var bgEquirect: texture_2d<f32>;\n\n// Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).\nconst FILMIC_LUT_W: f32 = 256.0;\n\nfn filmic(x: f32) -> f32 {\n // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender\n // look_medium-high-contrast.spi1d): linear 0.18 \u2192 ~0.395, linear 1.0 \u2192 ~0.83.\n // NOTE: version-pinned to Blender 3.6 \u2014 4.x defaults to AgX, not Filmic.\n let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);\n // Map t\u2208[0,13] to the texel-center of baked sample j = t\u00B7(W-1)/13.\n let u = (t * (FILMIC_LUT_W - 1.0) / 13.0 + 0.5) / FILMIC_LUT_W;\n // textureSampleLevel (explicit LOD, no derivatives) is legal in non-uniform flow.\n return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;\n}\n\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n@fragment fn fs(@builtin(position) fragCoord: vec4f) -> @location(0) vec4f {\n let coord = vec2<i32>(fragCoord.xy);\n let hdr = textureLoad(hdrTex, coord, 0);\n let alpha = textureLoad(maskTex, coord, 0).g;\n let a = max(alpha, 1e-6);\n let straight = hdr.rgb / a;\n let fullSz = vec2f(textureDimensions(hdrTex));\n // Bloom is at half-res (pyramid mip 0). Sampler interpolates back to full-res UVs.\n // fragCoord.xy is already at pixel center (e.g. 0.5, 0.5 for first pixel).\n let bloomUv = fragCoord.xy / max(fullSz, vec2f(1.0));\n let tint = viewU[1].xyz;\n let intensity = viewU[1].w;\n let bloom = textureSampleLevel(bloomTex, bloomSamp, bloomUv, 0.0).rgb * tint * intensity;\n let combined = straight + bloom;\n let exposed = combined * exp2(viewU[0].x);\n let tm = vec3f(filmic(exposed.r), filmic(exposed.g), filmic(exposed.b));\n var disp = max(tm, vec3f(0.0));\n if (APPLY_GAMMA) {\n disp = pow(disp, vec3f(viewU[0].y));\n }\n // Composite over the background in display space (premultiplied out).\n let bg = viewU[2];\n var bgRgb = bg.rgb;\n var bgA = select(0.0, 1.0, bg.w > 0.5);\n if (bg.w > 1.5) {\n // 360 equirect: rebuild this pixel's world-space view ray from the camera\n // basis, then latitude/longitude-map into the panorama. The dome sits at\n // infinity (no parallax) \u2014 PhotoDome-style, display-only.\n let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);\n let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);\n // LH world (+Z forward): longitude = atan2(x, z), Babylon-PhotoDome convention.\n let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2\u03C0)\n let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1/\u03C0\n bgRgb = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;\n }\n return vec4f(disp * alpha + bgRgb * bgA * (1.0 - alpha), alpha + bgA * (1.0 - alpha));\n}\n";
2
2
  //# sourceMappingURL=composite.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,qBAAqB,s0HAuEjC,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,qBAAqB,yvKA4FjC,CAAA"}
@@ -11,7 +11,7 @@ override APPLY_GAMMA: bool = true;
11
11
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
12
12
  @group(0) @binding(1) var bloomTex: texture_2d<f32>; // bloomUpTexture mip 0 (full pyramid top)
13
13
  @group(0) @binding(2) var bloomSamp: sampler;
14
- @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 2>;
14
+ @group(0) @binding(3) var<uniform> viewU: array<vec4<f32>, 6>;
15
15
  // Aux mask/alpha texture. .r = bloom mask (unused here; bloom blit uses it).
16
16
  // .g = accumulated canvas alpha (what hdr.a carried before the HDR format
17
17
  // became rg11b10ufloat). We unpremultiply HDR by this alpha for tonemap, then
@@ -27,7 +27,13 @@ override APPLY_GAMMA: bool = true;
27
27
  // continuity kills the banding — sampled with hardware linear filtering.
28
28
  @group(0) @binding(5) var filmicLut: texture_2d<f32>;
29
29
  // viewU[0] = (exposure, invGamma, _, _); viewU[1] = (tint.rgb, intensity)
30
+ // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
31
+ // scene post-tonemap. mode: 0 transparent (DOM shows), 1 solid color,
32
+ // 2 = 360 equirect skybox sampled by view ray.
33
+ // viewU[3] = (camera right, tanHalfFov·aspect); viewU[4] = (camera up, tanHalfFov);
34
+ // viewU[5] = (camera forward, _) — refreshed per frame while the skybox is active.
30
35
  // invGamma = 1/gamma precomputed on CPU — avoids a per-pixel divide.
36
+ @group(0) @binding(6) var bgEquirect: texture_2d<f32>;
31
37
 
32
38
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
33
39
  const FILMIC_LUT_W: f32 = 256.0;
@@ -69,6 +75,21 @@ fn filmic(x: f32) -> f32 {
69
75
  if (APPLY_GAMMA) {
70
76
  disp = pow(disp, vec3f(viewU[0].y));
71
77
  }
72
- return vec4f(disp * alpha, alpha);
78
+ // Composite over the background in display space (premultiplied out).
79
+ let bg = viewU[2];
80
+ var bgRgb = bg.rgb;
81
+ var bgA = select(0.0, 1.0, bg.w > 0.5);
82
+ if (bg.w > 1.5) {
83
+ // 360 equirect: rebuild this pixel's world-space view ray from the camera
84
+ // basis, then latitude/longitude-map into the panorama. The dome sits at
85
+ // infinity (no parallax) — PhotoDome-style, display-only.
86
+ let ndc = vec2f(fragCoord.x / fullSz.x * 2.0 - 1.0, 1.0 - fragCoord.y / fullSz.y * 2.0);
87
+ let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
88
+ // LH world (+Z forward): longitude = atan2(x, z), Babylon-PhotoDome convention.
89
+ let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2π)
90
+ let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1/π
91
+ bgRgb = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;
92
+ }
93
+ return vec4f(disp * alpha + bgRgb * bgA * (1.0 - alpha), alpha + bgA * (1.0 - alpha));
73
94
  }
74
95
  `;
@@ -1,2 +1,2 @@
1
- export declare const GROUND_SHADOW_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };\nstruct Light { direction: vec4f, color: vec4f, };\nstruct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };\nstruct GroundShadowMat {\n diffuseColor: vec3f, fadeStart: f32,\n fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,\n gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, _pad: f32,\n gridLineColor: vec3f, _pad2: f32,\n};\nstruct LightVP { viewProj: mat4x4f, };\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var shadowMap: texture_depth_2d;\n@group(0) @binding(3) var shadowSampler: sampler_comparison;\n@group(0) @binding(4) var<uniform> material: GroundShadowMat;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n\nfn hash2(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));\n return fract((p3.x + p3.y) * p3.z);\n}\nfn valueNoise(p: vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),\n mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);\n}\nfn fbmNoise(p: vec2f) -> f32 {\n var v = 0.0;\n var a = 0.5;\n var pp = p;\n for (var i = 0; i < 4; i++) {\n v += a * valueNoise(pp);\n pp *= 2.0;\n a *= 0.5;\n }\n return v;\n}\n\nstruct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };\n@vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {\n var o: VO; o.worldPos = position; o.normal = normal;\n o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;\n}\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };\n@fragment fn fs(i: VO) -> FSOut {\n let n = normalize(i.normal);\n let centerDist = length(i.worldPos.xz);\n let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));\n\n let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));\n let st = material.pcfTexel;\n let compareZ = ndc.z - 0.0035;\n var vis = 0.0;\n for (var y = -2; y <= 2; y++) {\n for (var x = -2; x <= 2; x++) {\n vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);\n }\n }\n vis *= 0.04;\n\n // Frosted/matte micro-texture\n let noiseVal = fbmNoise(i.worldPos.xz * 3.0);\n let noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;\n\n // Grid lines \u2014 anti-aliased via screen-space derivatives\n let gp = i.worldPos.xz / material.gridSpacing;\n let gridFrac = abs(fract(gp - 0.5) - 0.5);\n let gridDeriv = fwidth(gp);\n let halfLine = material.gridLineWidth * 0.5;\n let gridLine = 1.0 - min(\n smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),\n smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)\n );\n let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);\n let dark = (1.0 - vis) * material.shadowStrength;\n var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);\n baseColor *= noiseTint;\n let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);\n var out: FSOut;\n out.color = vec4f(finalColor * edgeFade, edgeFade);\n // mask.r = 0: ground never contributes to bloom. mask.g = 1.0 with src.a =\n // edgeFade turns the aux blend into alpha-over, so the drawable alpha fades\n // from edgeFade at the center to 0 at the radial edge \u2014 letting the page\n // background show through under the premultiplied canvas alphaMode.\n out.mask = vec4f(0.0, 1.0, 0.0, edgeFade);\n return out;\n}\n";
1
+ export declare const GROUND_SHADOW_SHADER_WGSL = "\nstruct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };\nstruct Light { direction: vec4f, color: vec4f, };\nstruct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };\nstruct GroundShadowMat {\n diffuseColor: vec3f, fadeStart: f32,\n fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,\n gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, opacity: f32,\n gridLineColor: vec3f, _pad2: f32,\n};\nstruct LightVP { viewProj: mat4x4f, };\n@group(0) @binding(0) var<uniform> camera: CameraUniforms;\n@group(0) @binding(1) var<uniform> light: LightUniforms;\n@group(0) @binding(2) var shadowMap: texture_depth_2d;\n@group(0) @binding(3) var shadowSampler: sampler_comparison;\n@group(0) @binding(4) var<uniform> material: GroundShadowMat;\n@group(0) @binding(5) var<uniform> lightVP: LightVP;\n\nfn hash2(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 += dot(p3, vec3f(p3.y + 33.33, p3.z + 33.33, p3.x + 33.33));\n return fract((p3.x + p3.y) * p3.z);\n}\nfn valueNoise(p: vec2f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n return mix(mix(hash2(i), hash2(i + vec2f(1.0, 0.0)), u.x),\n mix(hash2(i + vec2f(0.0, 1.0)), hash2(i + vec2f(1.0, 1.0)), u.x), u.y);\n}\nfn fbmNoise(p: vec2f) -> f32 {\n var v = 0.0;\n var a = 0.5;\n var pp = p;\n for (var i = 0; i < 4; i++) {\n v += a * valueNoise(pp);\n pp *= 2.0;\n a *= 0.5;\n }\n return v;\n}\n\nstruct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };\n@vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {\n var o: VO; o.worldPos = position; o.normal = normal;\n o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;\n}\nstruct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };\n@fragment fn fs(i: VO) -> FSOut {\n let n = normalize(i.normal);\n let centerDist = length(i.worldPos.xz);\n let edgeFade = 1.0 - smoothstep(0.0, 1.0, clamp((centerDist - material.fadeStart) / max(material.fadeEnd - material.fadeStart, 0.001), 0.0, 1.0));\n\n let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);\n let ndc = lclip.xyz / max(lclip.w, 1e-6);\n let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);\n let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));\n let st = material.pcfTexel;\n let compareZ = ndc.z - 0.0035;\n var vis = 0.0;\n for (var y = -2; y <= 2; y++) {\n for (var x = -2; x <= 2; x++) {\n vis += textureSampleCompare(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);\n }\n }\n vis *= 0.04;\n\n // Frosted/matte micro-texture\n let noiseVal = fbmNoise(i.worldPos.xz * 3.0);\n let noiseTint = 1.0 + (noiseVal - 0.5) * material.noiseStrength;\n\n // Grid lines \u2014 anti-aliased via screen-space derivatives\n let gp = i.worldPos.xz / material.gridSpacing;\n let gridFrac = abs(fract(gp - 0.5) - 0.5);\n let gridDeriv = fwidth(gp);\n let halfLine = material.gridLineWidth * 0.5;\n let gridLine = 1.0 - min(\n smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),\n smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)\n );\n let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);\n let dark = (1.0 - vis) * material.shadowStrength;\n var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);\n baseColor *= noiseTint;\n let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);\n // Whole-ground opacity fades the SURFACE (color, grid) but the shadow stays \u2014\n // as opacity drops, the received shadow becomes a translucent dark layer\n // (Blender's Shadow Catcher), so models still feel grounded on a photo or\n // 360 backdrop. At opacity 1 this reduces exactly to the plain surface.\n let surfA = edgeFade * material.opacity;\n let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity);\n let outA = surfA + catchA;\n var out: FSOut;\n out.color = vec4f(finalColor * surfA, outA);\n // mask.r = 0: ground never contributes to bloom. mask.g = 1.0 with src.a =\n // outA turns the aux blend into alpha-over, so the drawable alpha goes\n // from outA at the center to 0 at the radial edge \u2014 letting the page\n // background show through under the premultiplied canvas alphaMode.\n out.mask = vec4f(0.0, 1.0, 0.0, outA);\n return out;\n}\n";
2
2
  //# sourceMappingURL=ground.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,yBAAyB,ijIA8FrC,CAAA"}
1
+ {"version":3,"file":"ground.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/ground.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,yBAAyB,2+IAqGrC,CAAA"}
@@ -7,7 +7,7 @@ struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
7
7
  struct GroundShadowMat {
8
8
  diffuseColor: vec3f, fadeStart: f32,
9
9
  fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,
10
- gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, _pad: f32,
10
+ gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, opacity: f32,
11
11
  gridLineColor: vec3f, _pad2: f32,
12
12
  };
13
13
  struct LightVP { viewProj: mat4x4f, };
@@ -85,13 +85,20 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
85
85
  var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
86
86
  baseColor *= noiseTint;
87
87
  let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
88
+ // Whole-ground opacity fades the SURFACE (color, grid) but the shadow stays —
89
+ // as opacity drops, the received shadow becomes a translucent dark layer
90
+ // (Blender's Shadow Catcher), so models still feel grounded on a photo or
91
+ // 360 backdrop. At opacity 1 this reduces exactly to the plain surface.
92
+ let surfA = edgeFade * material.opacity;
93
+ let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity);
94
+ let outA = surfA + catchA;
88
95
  var out: FSOut;
89
- out.color = vec4f(finalColor * edgeFade, edgeFade);
96
+ out.color = vec4f(finalColor * surfA, outA);
90
97
  // mask.r = 0: ground never contributes to bloom. mask.g = 1.0 with src.a =
91
- // edgeFade turns the aux blend into alpha-over, so the drawable alpha fades
92
- // from edgeFade at the center to 0 at the radial edge — letting the page
98
+ // outA turns the aux blend into alpha-over, so the drawable alpha goes
99
+ // from outA at the center to 0 at the radial edge — letting the page
93
100
  // background show through under the premultiplied canvas alphaMode.
94
- out.mask = vec4f(0.0, 1.0, 0.0, edgeFade);
101
+ out.mask = vec4f(0.0, 1.0, 0.0, outA);
95
102
  return out;
96
103
  }
97
104
  `;