ugly-game 0.5.18 → 0.5.19

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 (66) hide show
  1. package/dist/assets/anim.js +15 -3
  2. package/dist/assets/animMoat.js +20 -4
  3. package/dist/assets/assemble.js +50 -14
  4. package/dist/assets/genome.js +57 -13
  5. package/dist/assets/genomeExplore.js +24 -12
  6. package/dist/assets/geometry.js +14 -3
  7. package/dist/assets/gpuProps.d.ts +6 -1
  8. package/dist/assets/gpuProps.js +150 -26
  9. package/dist/assets/gpuSkin.d.ts +6 -1
  10. package/dist/assets/gpuSkin.js +159 -30
  11. package/dist/assets/meshlet.js +20 -6
  12. package/dist/assets/morph.js +3 -1
  13. package/dist/assets/pose.js +15 -4
  14. package/dist/assets/renderAssets.js +12 -5
  15. package/dist/assets/residency.js +13 -6
  16. package/dist/assets/retarget.js +6 -2
  17. package/dist/assets/rig.js +36 -8
  18. package/dist/assets/stream.js +11 -4
  19. package/dist/assets/style.js +1 -1
  20. package/dist/assets/ugm.js +31 -9
  21. package/dist/capture.d.ts +1 -3
  22. package/dist/capture.js +12 -2
  23. package/dist/determinismProbe.js +15 -3
  24. package/dist/dmath.js +38 -8
  25. package/dist/ecs.js +121 -25
  26. package/dist/frameProbe.js +28 -5
  27. package/dist/gpuDriven.js +83 -12
  28. package/dist/gpuGI.js +73 -17
  29. package/dist/gpuGraph.js +73 -16
  30. package/dist/gpuLit.js +75 -10
  31. package/dist/gpuPost.js +112 -27
  32. package/dist/gpuShadow.d.ts +7 -1
  33. package/dist/gpuShadow.js +343 -85
  34. package/dist/gpuShadowMap.js +19 -3
  35. package/dist/gpuSpot.js +119 -32
  36. package/dist/gpuSsao.js +14 -2
  37. package/dist/gpuTaa.js +136 -35
  38. package/dist/gpuTerrain.js +160 -18
  39. package/dist/gpuTiled.js +107 -16
  40. package/dist/gpuWorld.js +200 -55
  41. package/dist/input/ControlHints.js +105 -17
  42. package/dist/input/TouchControls.d.ts +1 -1
  43. package/dist/input/TouchControls.js +69 -10
  44. package/dist/input/browserBackend.js +83 -24
  45. package/dist/input/resolve.js +61 -19
  46. package/dist/ir.js +179 -34
  47. package/dist/kcc.js +30 -8
  48. package/dist/mat4.js +25 -4
  49. package/dist/moat.js +41 -8
  50. package/dist/net/commands.js +3 -1
  51. package/dist/net/lww.js +2 -1
  52. package/dist/net/reconcile.js +1 -1
  53. package/dist/net/roomCore.js +21 -6
  54. package/dist/net/roomSocket.js +25 -14
  55. package/dist/noise.js +8 -2
  56. package/dist/physics.js +15 -1
  57. package/dist/playSim.js +108 -24
  58. package/dist/playtestIR.d.ts +3 -2
  59. package/dist/playtestIR.js +48 -16
  60. package/dist/renderIR.js +21 -3
  61. package/dist/scheduler.js +84 -16
  62. package/dist/shaderGraph.js +135 -42
  63. package/dist/stateIR.js +24 -7
  64. package/dist/stateTree.js +66 -16
  65. package/dist/visualIR.js +18 -5
  66. package/package.json +8 -3
@@ -1,6 +1,18 @@
1
- const u16 = (v) => { const a = new Uint8Array(2); new DataView(a.buffer).setUint16(0, v, true); return a; };
2
- const u32 = (v) => { const a = new Uint8Array(4); new DataView(a.buffer).setUint32(0, v, true); return a; };
3
- const i16 = (v) => { const a = new Uint8Array(2); new DataView(a.buffer).setInt16(0, v, true); return a; };
1
+ const u16 = (v) => {
2
+ const a = new Uint8Array(2);
3
+ new DataView(a.buffer).setUint16(0, v, true);
4
+ return a;
5
+ };
6
+ const u32 = (v) => {
7
+ const a = new Uint8Array(4);
8
+ new DataView(a.buffer).setUint32(0, v, true);
9
+ return a;
10
+ };
11
+ const i16 = (v) => {
12
+ const a = new Uint8Array(2);
13
+ new DataView(a.buffer).setInt16(0, v, true);
14
+ return a;
15
+ };
4
16
  const f32s = (arr) => new Uint8Array(arr.buffer.slice(arr.byteOffset, arr.byteOffset + arr.byteLength));
5
17
  const f32 = (v) => f32s(Float32Array.of(v));
6
18
  export function packAnim(clip) {
@@ -26,7 +26,12 @@ export function continuityScan(trace, factor = 8, floor = 0.02) {
26
26
  violations.push({ tick: t, index: i, jump: a, ratio: a / base });
27
27
  }
28
28
  }
29
- return { ticks: T, components: C, violations, clean: violations.length === 0 };
29
+ return {
30
+ ticks: T,
31
+ components: C,
32
+ violations,
33
+ clean: violations.length === 0,
34
+ };
30
35
  }
31
36
  /** The animation IR: the deterministic per-tick bone-matrix palette for a clip sampled over `ticks`. */
32
37
  export function animationPaletteTrace(skel, clip, ticks) {
@@ -44,12 +49,23 @@ export function scanAnimationContinuity(skel, clip, ticks = 120, factor = 8, flo
44
49
  const r = continuityScan(animationPaletteTrace(skel, clip, ticks), factor, floor);
45
50
  const seen = new Set();
46
51
  const violations = [];
47
- for (const v of r.violations) { // dedupe to one entry per (tick,joint)
52
+ for (const v of r.violations) {
53
+ // dedupe to one entry per (tick,joint)
48
54
  const joint = Math.floor(v.index / 16), key = `${v.tick}:${joint}`;
49
55
  if (seen.has(key))
50
56
  continue;
51
57
  seen.add(key);
52
- violations.push({ clipTime: ticks > 1 ? (v.tick / (ticks - 1)) * clip.duration : 0, joint, element: v.index % 16, ratio: v.ratio });
58
+ violations.push({
59
+ clipTime: ticks > 1 ? (v.tick / (ticks - 1)) * clip.duration : 0,
60
+ joint,
61
+ element: v.index % 16,
62
+ ratio: v.ratio,
63
+ });
53
64
  }
54
- return { clean: violations.length === 0, ticks, duration: clip.duration, violations };
65
+ return {
66
+ clean: violations.length === 0,
67
+ ticks,
68
+ duration: clip.duration,
69
+ violations,
70
+ };
55
71
  }
@@ -12,7 +12,11 @@ import { dsin, dcos } from '../dmath';
12
12
  import { resolveKit } from './partkit';
13
13
  import { resolveRole } from './style';
14
14
  function applyPoint(M, x, y, z) {
15
- return [M[0] * x + M[4] * y + M[8] * z + M[12], M[1] * x + M[5] * y + M[9] * z + M[13], M[2] * x + M[6] * y + M[10] * z + M[14]];
15
+ return [
16
+ M[0] * x + M[4] * y + M[8] * z + M[12],
17
+ M[1] * x + M[5] * y + M[9] * z + M[13],
18
+ M[2] * x + M[6] * y + M[10] * z + M[14],
19
+ ];
16
20
  }
17
21
  /** Assemble a rigged creature mesh from a genome. Deterministic + style-conformant. */
18
22
  export function assemble(arch, kit, genome, guide) {
@@ -20,28 +24,41 @@ export function assemble(arch, kit, genome, guide) {
20
24
  const prop = genome.proportion;
21
25
  // 1) Deform the shared rig's rest translations (bone lengths). Default per-joint scale = [girth,height,
22
26
  // girth]; limb sockets add limbLength, head sockets add headScale. inverseBind stays ORIGINAL.
23
- const sv = arch.restLocals.map(() => [prop.girth, prop.height, prop.girth]);
27
+ const sv = arch.restLocals.map(() => [
28
+ prop.girth,
29
+ prop.height,
30
+ prop.girth,
31
+ ]);
24
32
  for (const s of arch.sockets) {
25
33
  const j = s.jointIndex;
26
34
  if (j < 0 || j >= sv.length)
27
35
  continue;
28
- const f = s.region === 'limb' ? prop.limbLength : s.region === 'head' ? prop.headScale : 1;
36
+ const f = s.region === 'limb'
37
+ ? prop.limbLength
38
+ : s.region === 'head'
39
+ ? prop.headScale
40
+ : 1;
29
41
  sv[j] = [sv[j][0] * f, sv[j][1] * f, sv[j][2] * f];
30
42
  }
31
43
  const restLocals = arch.restLocals.map((r, j) => ({
32
44
  t: [r.t[0] * sv[j][0], r.t[1] * sv[j][1], r.t[2] * sv[j][2]],
33
- q: [r.q[0], r.q[1], r.q[2], r.q[3]], s: [r.s[0], r.s[1], r.s[2]],
45
+ q: [r.q[0], r.q[1], r.q[2], r.q[3]],
46
+ s: [r.s[0], r.s[1], r.s[2]],
34
47
  }));
35
48
  // 2) Weld each selected part (deterministic socket order) with its per-part deform, concatenating
36
49
  // positions/indices/joints/weights into one mesh sharing the archetype's parents + inverseBind.
37
50
  const positions = [], indices = [], joints = [], weights = [], roles = [];
38
51
  let minx = Infinity, miny = Infinity, minz = Infinity, maxx = -Infinity, maxy = -Infinity, maxz = -Infinity;
39
- const sockets = [...arch.sockets].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
52
+ const sockets = [...arch.sockets].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
40
53
  for (const s of sockets) {
41
54
  const part = selected[s.id];
42
55
  if (!part)
43
56
  continue;
44
- const d = genome.partDeform[s.id] ?? { scale: [1, 1, 1], stretch: [1, 1, 1], twist: 0 };
57
+ const d = genome.partDeform[s.id] ?? {
58
+ scale: [1, 1, 1],
59
+ stretch: [1, 1, 1],
60
+ twist: 0,
61
+ };
45
62
  // socket joint bind position (= translation of the inverse of its inverseBind) → deform pivots here
46
63
  const j = s.jointIndex, ib = new Float64Array(16);
47
64
  for (let i = 0; i < 16; i++)
@@ -51,7 +68,11 @@ export function assemble(arch, kit, genome, guide) {
51
68
  const half = d.twist / 2, qy = [0, dsin(half), 0, dcos(half)];
52
69
  const Tp = composeTRS([bx, by, bz], [0, 0, 0, 1], [1, 1, 1]);
53
70
  const R = composeTRS([0, 0, 0], qy, [1, 1, 1]);
54
- const S = composeTRS([0, 0, 0], [0, 0, 0, 1], [d.scale[0] * d.stretch[0], d.scale[1] * d.stretch[1], d.scale[2] * d.stretch[2]]);
71
+ const S = composeTRS([0, 0, 0], [0, 0, 0, 1], [
72
+ d.scale[0] * d.stretch[0],
73
+ d.scale[1] * d.stretch[1],
74
+ d.scale[2] * d.stretch[2],
75
+ ]);
55
76
  const Tn = composeTRS([-bx, -by, -bz], [0, 0, 0, 1], [1, 1, 1]);
56
77
  const W = mul(Tp, mul(R, mul(S, Tn)));
57
78
  const base = positions.length / 3, pv = part.mesh.positions.length / 3;
@@ -75,18 +96,33 @@ export function assemble(arch, kit, genome, guide) {
75
96
  ws += part.weights[v * 4 + k];
76
97
  for (let k = 0; k < 4; k++) {
77
98
  joints.push(part.joints[v * 4 + k]);
78
- weights.push(ws > 0 ? part.weights[v * 4 + k] / ws : (k === 0 ? 1 : 0));
99
+ weights.push(ws > 0 ? part.weights[v * 4 + k] / ws : k === 0 ? 1 : 0);
79
100
  }
80
101
  }
81
102
  for (const idx of part.mesh.indices)
82
103
  indices.push(idx + base);
83
- roles.push({ first: base, count: pv, region: part.tag.region, role: resolveRole(guide, part.tag.region) });
104
+ roles.push({
105
+ first: base,
106
+ count: pv,
107
+ region: part.tag.region,
108
+ role: resolveRole(guide, part.tag.region),
109
+ });
84
110
  }
85
111
  return {
86
- mesh: { positions: new Float32Array(positions), indices: new Uint32Array(indices) },
87
- jointCount: arch.jointCount, parents: arch.parents, inverseBind: arch.inverseBind,
88
- jointsPerVertex: new Uint16Array(joints), weightsPerVertex: new Float32Array(weights),
89
- restLocals, rootWorld: arch.rootWorld, roles,
90
- bounds: positions.length ? [minx, miny, minz, maxx, maxy, maxz] : [0, 0, 0, 0, 0, 0],
112
+ mesh: {
113
+ positions: new Float32Array(positions),
114
+ indices: new Uint32Array(indices),
115
+ },
116
+ jointCount: arch.jointCount,
117
+ parents: arch.parents,
118
+ inverseBind: arch.inverseBind,
119
+ jointsPerVertex: new Uint16Array(joints),
120
+ weightsPerVertex: new Float32Array(weights),
121
+ restLocals,
122
+ rootWorld: arch.rootWorld,
123
+ roles,
124
+ bounds: positions.length
125
+ ? [minx, miny, minz, maxx, maxy, maxz]
126
+ : [0, 0, 0, 0, 0, 0],
91
127
  };
92
128
  }
@@ -11,13 +11,22 @@ import { partsForSocket } from './partkit';
11
11
  /** Deterministic mulberry32 (local copy so the platform module has no game-code dependency). */
12
12
  function rng(seed) {
13
13
  let a = seed >>> 0;
14
- return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
14
+ return () => {
15
+ a = (a + 0x6d2b79f5) | 0;
16
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
17
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
18
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
19
+ };
15
20
  }
16
21
  const lerp = (r, lo, hi) => lo + (hi - lo) * r;
17
- function hashStr(s) { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) {
18
- h ^= s.charCodeAt(i);
19
- h = Math.imul(h, 16777619);
20
- } return h >>> 0; }
22
+ function hashStr(s) {
23
+ let h = 2166136261 >>> 0;
24
+ for (let i = 0; i < s.length; i++) {
25
+ h ^= s.charCodeAt(i);
26
+ h = Math.imul(h, 16777619);
27
+ }
28
+ return h >>> 0;
29
+ }
21
30
  /** Sample a full genome from a seed: pick a part per socket, deform it, set global proportion + palette.
22
31
  * Per-field seed splits keep draws independent (adding a field can't perturb earlier ones). */
23
32
  export function sampleGenome(arch, kit, seed) {
@@ -29,27 +38,62 @@ export function sampleGenome(arch, kit, seed) {
29
38
  if (opts.length)
30
39
  parts[s.id] = opts[Math.floor(rp() * opts.length) % opts.length].tag.id;
31
40
  partDeform[s.id] = {
32
- scale: [lerp(rd(), 0.85, 1.15), lerp(rd(), 0.85, 1.15), lerp(rd(), 0.85, 1.15)],
33
- stretch: [lerp(rd(), 0.9, 1.1), lerp(rd(), 0.9, 1.1), lerp(rd(), 0.9, 1.1)],
41
+ scale: [
42
+ lerp(rd(), 0.85, 1.15),
43
+ lerp(rd(), 0.85, 1.15),
44
+ lerp(rd(), 0.85, 1.15),
45
+ ],
46
+ stretch: [
47
+ lerp(rd(), 0.9, 1.1),
48
+ lerp(rd(), 0.9, 1.1),
49
+ lerp(rd(), 0.9, 1.1),
50
+ ],
34
51
  twist: lerp(rd(), -0.3, 0.3),
35
52
  };
36
53
  }
37
54
  const rprop = rng(seed ^ 0x3003);
38
- const proportion = { height: lerp(rprop(), 0.8, 1.3), girth: lerp(rprop(), 0.85, 1.2), limbLength: lerp(rprop(), 0.85, 1.2), headScale: lerp(rprop(), 0.85, 1.25) };
55
+ const proportion = {
56
+ height: lerp(rprop(), 0.8, 1.3),
57
+ girth: lerp(rprop(), 0.85, 1.2),
58
+ limbLength: lerp(rprop(), 0.85, 1.2),
59
+ headScale: lerp(rprop(), 0.85, 1.25),
60
+ };
39
61
  const rpal = rng(seed ^ 0x4004);
40
62
  const roleRamp = {};
41
63
  for (const s of arch.sockets)
42
64
  roleRamp[s.region] ??= Math.floor(rpal() * 6);
43
- return { archetypeId: arch.id, partKitVersion: kit.version, seed, parts, partDeform, proportion, palette: { guideId: '', roleRamp } };
65
+ return {
66
+ archetypeId: arch.id,
67
+ partKitVersion: kit.version,
68
+ seed,
69
+ parts,
70
+ partDeform,
71
+ proportion,
72
+ palette: { guideId: '', roleRamp },
73
+ };
44
74
  }
45
75
  /** Stable canonical string (sorted keys, fixed float precision) → the cache/replay identity. */
46
76
  export function canonicalizeGenome(g) {
47
77
  const fx = (n) => n.toFixed(6);
48
- const parts = Object.keys(g.parts).sort().map((k) => `${k}:${g.parts[k]}`).join(',');
49
- const def = Object.keys(g.partDeform).sort().map((k) => { const d = g.partDeform[k]; return `${k}:${d.scale.map(fx)}|${d.stretch.map(fx)}|${fx(d.twist)}`; }).join(',');
78
+ const parts = Object.keys(g.parts)
79
+ .sort()
80
+ .map((k) => `${k}:${g.parts[k]}`)
81
+ .join(',');
82
+ const def = Object.keys(g.partDeform)
83
+ .sort()
84
+ .map((k) => {
85
+ const d = g.partDeform[k];
86
+ return `${k}:${d.scale.map(fx)}|${d.stretch.map(fx)}|${fx(d.twist)}`;
87
+ })
88
+ .join(',');
50
89
  const p = g.proportion, prop = [p.height, p.girth, p.limbLength, p.headScale].map(fx).join(',');
51
- const ramp = Object.keys(g.palette.roleRamp).sort().map((k) => `${k}:${g.palette.roleRamp[k]}`).join(',');
90
+ const ramp = Object.keys(g.palette.roleRamp)
91
+ .sort()
92
+ .map((k) => `${k}:${g.palette.roleRamp[k]}`)
93
+ .join(',');
52
94
  return `${g.archetypeId}#${g.partKitVersion}#${parts}#${def}#${prop}#${g.palette.guideId}:${ramp}`;
53
95
  }
54
96
  /** Content hash including the kit version (a kit change invalidates caches deterministically). */
55
- export function genomeHash(g) { return fnv1a(canonicalizeGenome(g) + '|' + String(g.partKitVersion)); }
97
+ export function genomeHash(g) {
98
+ return fnv1a(canonicalizeGenome(g) + '|' + String(g.partKitVersion));
99
+ }
@@ -10,21 +10,32 @@
10
10
  import { sampleGenome } from './genome';
11
11
  import { assemble } from './assemble';
12
12
  import { composeTRS, forwardKinematics, buildPalette } from './pose';
13
- function toMat(arr) { const m = new Float64Array(16); for (let i = 0; i < 16; i++)
14
- m[i] = arr[i] ?? 0; return m; }
13
+ function toMat(arr) {
14
+ const m = new Float64Array(16);
15
+ for (let i = 0; i < 16; i++)
16
+ m[i] = arr[i] ?? 0;
17
+ return m;
18
+ }
15
19
  /** The largest VERTICAL gap between an assembled creature's parts (by role vertex-range Y-extents). A
16
20
  * big gap means a body region has NO geometry — e.g. dropped mesh primitives (a multi-material sub-mesh
17
21
  * silently skipped) or a missing socket. Structural validity + bind correctness both MISS this; it is
18
22
  * the mesh-COMPLETENESS check the pipeline lacked (it hid the "floating head / no chest" render bug). */
19
23
  export function verticalCoverageGap(a) {
20
24
  const pos = a.mesh.positions;
21
- const ranges = a.roles.map((r) => { let lo = Infinity, hi = -Infinity; for (let v = r.first; v < r.first + r.count; v++) {
22
- const y = pos[v * 3 + 1];
23
- if (y < lo)
24
- lo = y;
25
- if (y > hi)
26
- hi = y;
27
- } return [lo, hi]; }).filter((r) => r[0] <= r[1]).sort((p, q) => p[0] - q[0]);
25
+ const ranges = a.roles
26
+ .map((r) => {
27
+ let lo = Infinity, hi = -Infinity;
28
+ for (let v = r.first; v < r.first + r.count; v++) {
29
+ const y = pos[v * 3 + 1];
30
+ if (y < lo)
31
+ lo = y;
32
+ if (y > hi)
33
+ hi = y;
34
+ }
35
+ return [lo, hi];
36
+ })
37
+ .filter((r) => r[0] <= r[1])
38
+ .sort((p, q) => p[0] - q[0]);
28
39
  if (ranges.length < 2)
29
40
  return 0;
30
41
  let cover = ranges[0][1], maxGap = 0;
@@ -57,7 +68,7 @@ export function auditGenome(arch, kit, guide, g) {
57
68
  const failures = [];
58
69
  try {
59
70
  for (const s of arch.sockets)
60
- if (g.parts[s.id] === undefined)
71
+ if (!(s.id in g.parts))
61
72
  failures.push(`socket ${s.id} unfilled`);
62
73
  const a = assemble(arch, kit, g, guide);
63
74
  const vc = a.mesh.positions.length / 3;
@@ -79,13 +90,14 @@ export function auditGenome(arch, kit, guide, g) {
79
90
  failures.push('skin weights do not sum to 1');
80
91
  if (a.roles.reduce((t, r) => t + r.count, 0) !== vc)
81
92
  failures.push('roles do not cover all vertices');
82
- if (a.roles.some((r) => guide.roles[r.role] === undefined))
93
+ if (a.roles.some((r) => !(r.role in guide.roles)))
83
94
  failures.push('assigned role not in StyleGuide');
84
95
  const gap = verticalCoverageGap(a), height = a.bounds[4] - a.bounds[1];
85
96
  if (height > 0 && gap > 0.12 * height)
86
97
  failures.push(`vertical geometry gap ${gap.toFixed(2)} of ${height.toFixed(2)} tall — a body region has no mesh (dropped primitive / missing part?)`);
87
98
  const b = assemble(arch, kit, g, guide);
88
- if (JSON.stringify([...a.mesh.positions]) !== JSON.stringify([...b.mesh.positions]))
99
+ if (JSON.stringify([...a.mesh.positions]) !==
100
+ JSON.stringify([...b.mesh.positions]))
89
101
  failures.push('non-deterministic assemble');
90
102
  }
91
103
  catch (e) {
@@ -9,7 +9,9 @@ import { MeshoptEncoder, MeshoptDecoder } from 'meshoptimizer';
9
9
  const POS_STRIDE = 8; // 4 × uint16 (x,y,z + pad) — meshopt requires stride % 4 == 0; the constant
10
10
  // pad channel costs ~nothing after encoding.
11
11
  const AABB_BYTES = 24; // 6 × float32: min.xyz, max.xyz, prepended to the vtx payload.
12
- const quant = (v, lo, hi) => hi > lo ? Math.max(0, Math.min(65535, Math.round((v - lo) / (hi - lo) * 65535))) : 0;
12
+ const quant = (v, lo, hi) => hi > lo
13
+ ? Math.max(0, Math.min(65535, Math.round(((v - lo) / (hi - lo)) * 65535)))
14
+ : 0;
13
15
  function bounds3(p) {
14
16
  let a = Infinity, b = Infinity, c = Infinity, d = -Infinity, e = -Infinity, f = -Infinity;
15
17
  for (let i = 0; i < p.length; i += 3) {
@@ -51,7 +53,13 @@ export function compactMesh(positions, indices, uvs) {
51
53
  }
52
54
  outIdx[i] = n;
53
55
  }
54
- return uvs ? { positions: Float32Array.from(posOut), indices: outIdx, uvs: Float32Array.from(uvOut) } : { positions: Float32Array.from(posOut), indices: outIdx };
56
+ return uvs
57
+ ? {
58
+ positions: Float32Array.from(posOut),
59
+ indices: outIdx,
60
+ uvs: Float32Array.from(uvOut),
61
+ }
62
+ : { positions: Float32Array.from(posOut), indices: outIdx };
55
63
  }
56
64
  export async function encodeGeometry(m) {
57
65
  await MeshoptEncoder.ready;
@@ -86,7 +94,10 @@ export async function decodeGeometry(enc) {
86
94
  }
87
95
  const idxBytes = new Uint8Array(enc.indexCount * 4);
88
96
  MeshoptDecoder.decodeIndexBuffer(idxBytes, enc.indexCount, 4, enc.idx);
89
- return { positions, indices: new Uint32Array(idxBytes.buffer, idxBytes.byteOffset, enc.indexCount) };
97
+ return {
98
+ positions,
99
+ indices: new Uint32Array(idxBytes.buffer, idxBytes.byteOffset, enc.indexCount),
100
+ };
90
101
  }
91
102
  // ── UV (TEXCOORD_0): 16-bit quantized within its 2D bounds, meshopt-encoded, shared across LODs ──
92
103
  const UV_STRIDE = 4; // 2 × uint16
@@ -19,11 +19,16 @@ export declare class GpuPropsRenderer {
19
19
  private bgs;
20
20
  private occBGs;
21
21
  private counts;
22
+ private dummyShadowView;
23
+ private dummyShadowSamp;
24
+ private shadowBG;
25
+ private lastSV;
26
+ private lastSS;
22
27
  constructor(device: GPUDevice, format: GPUTextureFormat);
23
28
  setModels(meshes: PropMesh[]): void;
24
29
  /** Group instances by model and upload per-model transform buffers. */
25
30
  setInstances(instances: PropInstance[]): void;
26
31
  /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
27
32
  shadowPass(pass: GPURenderPassEncoder, lightVP: Float32Array): void;
28
- frame(enc: GPUCommandEncoder, colorView: GPUTextureView, depthView: GPUTextureView, camVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], amb: [number, number, number], camPos: [number, number, number], fog: [number, number, number], fogDensity: number, up?: [number, number, number]): void;
33
+ frame(enc: GPUCommandEncoder, colorView: GPUTextureView, depthView: GPUTextureView, camVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], amb: [number, number, number], camPos: [number, number, number], fog: [number, number, number], fogDensity: number, up?: [number, number, number], lightVP?: Float32Array, shadowView?: GPUTextureView, shadowSamp?: GPUSampler): void;
29
34
  }
@@ -4,9 +4,11 @@
4
4
  // terrain. Runs in a load pass after the terrain so props depth-test against the surface.
5
5
  // ─────────────────────────────────────────────────────────────────────────────
6
6
  const SHADER = /* wgsl */ `
7
- struct U { mvp: mat4x4<f32>, sun: vec4<f32>, sunCol: vec4<f32>, amb: vec4<f32>, cam: vec4<f32>, fog: vec4<f32>, up: vec4<f32> };
7
+ struct U { mvp: mat4x4<f32>, lightVP: mat4x4<f32>, sun: vec4<f32>, sunCol: vec4<f32>, amb: vec4<f32>, cam: vec4<f32>, fog: vec4<f32>, up: vec4<f32> };
8
8
  @group(0) @binding(0) var<uniform> u: U;
9
9
  @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
10
+ @group(1) @binding(0) var shadowMap: texture_depth_2d; // the SAME sun shadow map the terrain cubes sample
11
+ @group(1) @binding(1) var shadowSamp: sampler_comparison;
10
12
  struct VO { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec3<f32> };
11
13
  @vertex fn vs(@location(0) p: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) col: vec3<f32>, @builtin(instance_index) ii: u32) -> VO {
12
14
  let m = models[ii];
@@ -22,11 +24,28 @@ fn fixedFill(N: vec3<f32>, up: vec3<f32>) -> f32 {
22
24
  let L = normalize(up * 0.82 + t * 0.57);
23
25
  return max(dot(N, L), 0.0);
24
26
  }
27
+ // PCF 3×3 sun shadow — mirrors gpuShadow.shadowFactor so props sit in the SAME shadow as the terrain. 1 = lit, 0 = shadowed.
28
+ fn shadowFactor(world: vec3<f32>, ndl: f32) -> f32 {
29
+ let lp = u.lightVP * vec4(world, 1.0);
30
+ let proj = lp.xyz / lp.w;
31
+ let uv = proj.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5);
32
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0 || proj.z > 1.0 || proj.z < 0.0) { return 1.0; }
33
+ let bias = max(0.0006 * (1.0 - ndl), 0.0004);
34
+ let texel = 1.0 / 2048.0;
35
+ var s = 0.0;
36
+ for (var dy = -1; dy <= 1; dy = dy + 1) {
37
+ for (var dx = -1; dx <= 1; dx = dx + 1) {
38
+ s = s + textureSampleCompareLevel(shadowMap, shadowSamp, uv + vec2(f32(dx), f32(dy)) * texel, proj.z - bias);
39
+ }
40
+ }
41
+ return s / 9.0;
42
+ }
25
43
  @fragment fn fs(i: VO) -> @location(0) vec4<f32> {
26
44
  let N = normalize(i.n);
27
45
  let ff = select(1.0, 0.5 + 0.85 * fixedFill(N, u.up.xyz), u.up.w > 0.5); // fixed fill (fall back to flat amb if no up)
28
46
  let d = clamp(dot(N, -normalize(u.sun.xyz)), 0.0, 1.0);
29
- let lit = i.c * (u.amb.xyz * ff + d * u.sunCol.xyz);
47
+ let sh = mix(1.0, shadowFactor(i.world, d), u.sun.w); // u.sun.w = shadow-receive enable (0 ⇒ off, byte-identical for demo)
48
+ let lit = i.c * (u.amb.xyz * ff + d * u.sunCol.xyz * sh);
30
49
  let dist = length(i.world - u.cam.xyz);
31
50
  let f = clamp(1.0 - exp(-dist * u.fog.w), 0.0, 1.0);
32
51
  return vec4(mix(lit, u.fog.xyz, f), 1.0);
@@ -49,29 +68,79 @@ export class GpuPropsRenderer {
49
68
  bgs = [];
50
69
  occBGs = [];
51
70
  counts = [];
71
+ // shadow-receive (group 1): a dummy 1×1 depth map when no sun shadow is supplied (demo path), else the terrain's
72
+ // shared shadow map. The bind group is cached and only rebuilt when the supplied view/sampler identity changes.
73
+ dummyShadowView;
74
+ dummyShadowSamp;
75
+ shadowBG = null;
76
+ lastSV = null;
77
+ lastSS = null;
52
78
  constructor(device, format) {
53
79
  this.device = device;
54
80
  const sm = device.createShaderModule({ code: SHADER });
55
81
  this.pipe = device.createRenderPipeline({
56
82
  layout: 'auto',
57
- vertex: { module: sm, entryPoint: 'vs', buffers: [
58
- { arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] },
59
- { arrayStride: 12, attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }] },
60
- { arrayStride: 12, attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x3' }] }
61
- ] },
83
+ vertex: {
84
+ module: sm,
85
+ entryPoint: 'vs',
86
+ buffers: [
87
+ {
88
+ arrayStride: 12,
89
+ attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }],
90
+ },
91
+ {
92
+ arrayStride: 12,
93
+ attributes: [{ shaderLocation: 1, offset: 0, format: 'float32x3' }],
94
+ },
95
+ {
96
+ arrayStride: 12,
97
+ attributes: [{ shaderLocation: 2, offset: 0, format: 'float32x3' }],
98
+ },
99
+ ],
100
+ },
62
101
  fragment: { module: sm, entryPoint: 'fs', targets: [{ format }] },
63
102
  primitive: { topology: 'triangle-list', cullMode: 'none' },
64
- depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
103
+ depthStencil: {
104
+ format: 'depth24plus',
105
+ depthWriteEnabled: true,
106
+ depthCompare: 'less',
107
+ },
65
108
  });
66
109
  const om = device.createShaderModule({ code: OCCLUDER });
67
110
  this.occPipe = device.createRenderPipeline({
68
111
  layout: 'auto',
69
- vertex: { module: om, entryPoint: 'vs', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }] },
112
+ vertex: {
113
+ module: om,
114
+ entryPoint: 'vs',
115
+ buffers: [
116
+ {
117
+ arrayStride: 12,
118
+ attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }],
119
+ },
120
+ ],
121
+ },
70
122
  primitive: { topology: 'triangle-list', cullMode: 'none' },
71
- depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' },
123
+ depthStencil: {
124
+ format: 'depth32float',
125
+ depthWriteEnabled: true,
126
+ depthCompare: 'less',
127
+ },
128
+ });
129
+ this.uBuf = device.createBuffer({
130
+ size: 128 + 16 * 6, // 2× mat4 (mvp + lightVP) + 6× vec4
131
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
132
+ });
133
+ this.lightBuf = device.createBuffer({
134
+ size: 64,
135
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
72
136
  });
73
- this.uBuf = device.createBuffer({ size: 96 + 16 * 6, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
74
- this.lightBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
137
+ const dummy = device.createTexture({
138
+ size: [1, 1],
139
+ format: 'depth32float',
140
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
141
+ });
142
+ this.dummyShadowView = dummy.createView();
143
+ this.dummyShadowSamp = device.createSampler({ compare: 'less' });
75
144
  }
76
145
  setModels(meshes) {
77
146
  const dev = this.device, U = GPUBufferUsage;
@@ -81,8 +150,18 @@ export class GpuPropsRenderer {
81
150
  p.colB.destroy();
82
151
  p.idxB.destroy();
83
152
  }
84
- const mk = (d, u) => { const b = dev.createBuffer({ size: d.byteLength, usage: u }); dev.queue.writeBuffer(b, 0, d); return b; };
85
- this.models = meshes.map((m) => ({ posB: mk(m.positions, U.VERTEX | U.COPY_DST), nrmB: mk(m.normals, U.VERTEX | U.COPY_DST), colB: mk(m.colors, U.VERTEX | U.COPY_DST), idxB: mk(m.indices, U.INDEX | U.COPY_DST), idxCount: m.indices.length }));
153
+ const mk = (d, u) => {
154
+ const b = dev.createBuffer({ size: d.byteLength, usage: u });
155
+ dev.queue.writeBuffer(b, 0, d);
156
+ return b;
157
+ };
158
+ this.models = meshes.map((m) => ({
159
+ posB: mk(m.positions, U.VERTEX | U.COPY_DST),
160
+ nrmB: mk(m.normals, U.VERTEX | U.COPY_DST),
161
+ colB: mk(m.colors, U.VERTEX | U.COPY_DST),
162
+ idxB: mk(m.indices, U.INDEX | U.COPY_DST),
163
+ idxCount: m.indices.length,
164
+ }));
86
165
  this.instBufs = meshes.map(() => null);
87
166
  this.bgs = meshes.map(() => null);
88
167
  this.occBGs = meshes.map(() => null);
@@ -110,11 +189,26 @@ export class GpuPropsRenderer {
110
189
  this.occBGs[m] = null;
111
190
  continue;
112
191
  }
113
- const b = dev.createBuffer({ size: arr.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
192
+ const b = dev.createBuffer({
193
+ size: arr.byteLength,
194
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
195
+ });
114
196
  dev.queue.writeBuffer(b, 0, arr);
115
197
  this.instBufs[m] = b;
116
- this.bgs[m] = dev.createBindGroup({ layout: this.pipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: this.uBuf } }, { binding: 1, resource: { buffer: b } }] });
117
- this.occBGs[m] = dev.createBindGroup({ layout: this.occPipe.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: this.lightBuf } }, { binding: 1, resource: { buffer: b } }] });
198
+ this.bgs[m] = dev.createBindGroup({
199
+ layout: this.pipe.getBindGroupLayout(0),
200
+ entries: [
201
+ { binding: 0, resource: { buffer: this.uBuf } },
202
+ { binding: 1, resource: { buffer: b } },
203
+ ],
204
+ });
205
+ this.occBGs[m] = dev.createBindGroup({
206
+ layout: this.occPipe.getBindGroupLayout(0),
207
+ entries: [
208
+ { binding: 0, resource: { buffer: this.lightBuf } },
209
+ { binding: 1, resource: { buffer: b } },
210
+ ],
211
+ });
118
212
  }
119
213
  }
120
214
  /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
@@ -131,19 +225,45 @@ export class GpuPropsRenderer {
131
225
  pass.drawIndexed(mdl.idxCount, this.counts[m]);
132
226
  }
133
227
  }
134
- frame(enc, colorView, depthView, camVP, sunDir, sunCol, amb, camPos, fog, fogDensity, up) {
135
- const u = new Float32Array(24 + 24);
228
+ frame(enc, colorView, depthView, camVP, sunDir, sunCol, amb, camPos, fog, fogDensity, up,
229
+ // Optional sun shadow-receive: pass the terrain's lightVP + shadow-map view + comparison sampler and props sit
230
+ // in the same shadow. Omit all three (the demo path) → no shadowing, byte-identical to before.
231
+ lightVP, shadowView, shadowSamp) {
232
+ const receive = !!(lightVP && shadowView && shadowSamp);
233
+ const u = new Float32Array(56);
136
234
  u.set(camVP, 0);
137
- u.set([sunDir[0], sunDir[1], sunDir[2], 0], 16);
138
- u.set([sunCol[0], sunCol[1], sunCol[2], 0], 20);
139
- u.set([amb[0], amb[1], amb[2], 0], 24);
140
- u.set([camPos[0], camPos[1], camPos[2], 0], 28);
141
- u.set([fog[0], fog[1], fog[2], fogDensity], 32);
235
+ u.set(lightVP ?? IDENTITY4, 16); // lightVP (identity when not receiving — result is gated off by sun.w)
236
+ u.set([sunDir[0], sunDir[1], sunDir[2], receive ? 1 : 0], 32); // sun.w = shadow-receive enable
237
+ u.set([sunCol[0], sunCol[1], sunCol[2], 0], 36);
238
+ u.set([amb[0], amb[1], amb[2], 0], 40);
239
+ u.set([camPos[0], camPos[1], camPos[2], 0], 44);
240
+ u.set([fog[0], fog[1], fog[2], fogDensity], 48);
142
241
  if (up)
143
- u.set([up[0], up[1], up[2], 1], 36); // up.w=1 → enable the fixed directional fill (matches the terrain)
242
+ u.set([up[0], up[1], up[2], 1], 52); // up.w=1 → enable the fixed directional fill (matches the terrain)
144
243
  this.device.queue.writeBuffer(this.uBuf, 0, u);
145
- const rp = enc.beginRenderPass({ colorAttachments: [{ view: colorView, loadOp: 'load', storeOp: 'store' }], depthStencilAttachment: { view: depthView, depthLoadOp: 'load', depthStoreOp: 'store' } });
244
+ // group 1: the shadow map + comparison sampler (dummy when not receiving). Rebuild only when the view changes.
245
+ const sv = shadowView ?? this.dummyShadowView, ss = shadowSamp ?? this.dummyShadowSamp;
246
+ if (!this.shadowBG || sv !== this.lastSV || ss !== this.lastSS) {
247
+ this.shadowBG = this.device.createBindGroup({
248
+ layout: this.pipe.getBindGroupLayout(1),
249
+ entries: [
250
+ { binding: 0, resource: sv },
251
+ { binding: 1, resource: ss },
252
+ ],
253
+ });
254
+ this.lastSV = sv;
255
+ this.lastSS = ss;
256
+ }
257
+ const rp = enc.beginRenderPass({
258
+ colorAttachments: [{ view: colorView, loadOp: 'load', storeOp: 'store' }],
259
+ depthStencilAttachment: {
260
+ view: depthView,
261
+ depthLoadOp: 'load',
262
+ depthStoreOp: 'store',
263
+ },
264
+ });
146
265
  rp.setPipeline(this.pipe);
266
+ rp.setBindGroup(1, this.shadowBG);
147
267
  for (let m = 0; m < this.models.length; m++) {
148
268
  const bg = this.bgs[m], mdl = this.models[m];
149
269
  if (!bg || this.counts[m] === 0)
@@ -158,3 +278,7 @@ export class GpuPropsRenderer {
158
278
  rp.end();
159
279
  }
160
280
  }
281
+ // column-major identity mat4, used for lightVP when shadow-receive is off
282
+ const IDENTITY4 = new Float32Array([
283
+ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
284
+ ]);