ugly-game 0.7.9 → 0.7.10

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.
@@ -26,9 +26,37 @@ export interface PropMesh {
26
26
  colors: Float32Array;
27
27
  indices: Uint32Array;
28
28
  }
29
+ /**
30
+ * Per-instance deformation. Every field is neutral at its default, so an instance that supplies none
31
+ * renders byte-identically to one drawn before this existed.
32
+ *
33
+ * This is the cheap half of "variety": one generated mesh becomes an unlimited stand of visibly
34
+ * different plants without a second asset, a second draw call, or a provider call. The expensive
35
+ * half — new silhouettes — still needs a new mesh.
36
+ */
37
+ export interface PropDeform {
38
+ /** Vertical scale. 0.6 squat, 1.6 lanky. */
39
+ height?: number;
40
+ /** Horizontal scale, applied before taper. */
41
+ girth?: number;
42
+ /** >0 narrows toward the top, <0 flares. */
43
+ taper?: number;
44
+ /** Lateral tip displacement in units of the plant's own height; grows as u². */
45
+ bend?: number;
46
+ bendDir?: number;
47
+ /** Radians of twist from base to tip. */
48
+ twist?: number;
49
+ /** Downward pull on geometry that reaches out — a trunk is unaffected, a frond sags. */
50
+ droop?: number;
51
+ yaw?: number;
52
+ /** Multiplied into vertex colour; gives a within-species value spread. */
53
+ valueJitter?: number;
54
+ }
29
55
  export interface PropInstance {
30
56
  model: number;
31
57
  mat: Float32Array;
58
+ /** Optional per-instance deform. Omitted ⇒ the mesh is drawn exactly as authored. */
59
+ deform?: PropDeform;
32
60
  }
33
61
  export declare class GpuPropsRenderer {
34
62
  private device;
@@ -38,6 +66,8 @@ export declare class GpuPropsRenderer {
38
66
  private lightBuf;
39
67
  private models;
40
68
  private instBufs;
69
+ /** Allocated capacity in instances, so a per-frame LOD re-bucket reuses the buffer. */
70
+ private caps;
41
71
  private bgs;
42
72
  private occBGs;
43
73
  private counts;
@@ -59,7 +89,14 @@ export declare class GpuPropsRenderer {
59
89
  tick(dt: number): void;
60
90
  constructor(device: GPUDevice, format: GPUTextureFormat);
61
91
  setModels(meshes: PropMesh[]): void;
62
- /** Group instances by model and upload per-model transform buffers. */
92
+ /**
93
+ * Group instances by model and upload the per-model instance buffers.
94
+ *
95
+ * Safe to call every frame: buffers are REUSED whenever the new count fits the allocated capacity,
96
+ * and capacity grows with headroom. That matters because distance-bucketed LOD means an instance
97
+ * changes which model it belongs to as the camera moves, so the grouping is not a one-time setup —
98
+ * re-allocating a megabyte of storage buffers per frame would cost more than the LOD saves.
99
+ */
63
100
  setInstances(instances: PropInstance[]): void;
64
101
  /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
65
102
  shadowPass(pass: GPURenderPassEncoder, lightVP: Float32Array): void;
@@ -9,7 +9,15 @@ const SHADER = /* wgsl */ `
9
9
  // grade = (hueShift, saturation, valueLift, unused)
10
10
  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>, wind: vec4<f32>, surf: vec4<f32>, grade: vec4<f32> };
11
11
  @group(0) @binding(0) var<uniform> u: U;
12
- @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
12
+ // One instance. 'm' is the placement; d0/d1/d2 are the per-instance DEFORM, which is what lets
13
+ // thousands of visibly different plants share a single mesh and a single draw call. Baking each
14
+ // variant on the CPU instead costs one model, one vertex buffer and one draw call per plant —
15
+ // measured, that is the difference between a forest and a slideshow.
16
+ // d0 = (heightScale, girth, taper, 1/sourceHeight)
17
+ // d1 = (bend, bendDir, twist, droop)
18
+ // d2 = (yaw, valueJitter, 1/halfWidth, unused)
19
+ struct Inst { m: mat4x4<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32> };
20
+ @group(0) @binding(1) var<storage,read> models: array<Inst>;
13
21
  @group(1) @binding(0) var shadowMap: texture_depth_2d; // the SAME sun shadow map the terrain cubes sample
14
22
  @group(1) @binding(1) var shadowSamp: sampler_comparison;
15
23
  struct VO { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec3<f32>, @location(3) l: vec3<f32> };
@@ -28,13 +36,63 @@ fn sway(local: vec3<f32>, world: vec3<f32>, up: vec3<f32>, scale: f32) -> vec3<f
28
36
  wd = normalize(wd - up * dot(wd, up)); // project into the local tangent plane
29
37
  return world + wd * d;
30
38
  }
39
+ // PER-INSTANCE DEFORM, in local space, before placement. Mirrors shared/lifeVariation.ts deform()
40
+ // exactly: the CPU version is the reference and the two must agree, or the same seed gives a
41
+ // different plant depending on which path drew it.
42
+ //
43
+ // Assumes the model's base sits at y = 0, which the cook guarantees (it normalises every plant to
44
+ // base-at-origin). u then runs 0 at the base to 1 at the tip, and everything is anchored: a deformed
45
+ // plant still meets the ground exactly where the original did, so no instance needs its contact
46
+ // re-solved.
47
+ fn deformLocal(p: vec3<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32>) -> vec3<f32> {
48
+ let H = 1.0 / max(d0.w, 1e-6);
49
+ let u = clamp(p.y * d0.w, 0.0, 1.0);
50
+ var x = p.x; var y = p.y; var z = p.z;
51
+ let rs = d0.y * (1.0 + d0.z * (1.0 - 2.0 * u)); // taper about the vertical axis
52
+ x = x * rs; z = z * rs;
53
+ let a = d1.z * u; // twist accumulates with height
54
+ let ca = cos(a); let sa = sin(a);
55
+ let tx = x * ca - z * sa; let tz = x * sa + z * ca;
56
+ x = tx; z = tz;
57
+ y = y * d0.x;
58
+ if (d1.w != 0.0) { // droop: weighted by radial reach
59
+ let r = sqrt(x * x + z * z) * d2.z;
60
+ y = y - d1.w * H * d0.x * r * r * u;
61
+ }
62
+ let dd = d1.x * H * d0.x * u * u; // bend as u²: leaves the ground vertical
63
+ x = x + cos(d1.y) * dd;
64
+ z = z + sin(d1.y) * dd;
65
+ let cy = cos(d2.x); let sy = sin(d2.x);
66
+ return vec3(x * cy - z * sy, y, x * sy + z * cy);
67
+ }
68
+ /**
69
+ * Normal under the same deform, approximated.
70
+ *
71
+ * The exact answer is the inverse-transpose of the deform's Jacobian, which for a bend and a droop
72
+ * is not cheap. Taper and non-uniform height are the terms that actually matter to shading, so the
73
+ * normal gets the inverse non-uniform scale plus the rigid rotations (twist + yaw) and nothing else.
74
+ * The CPU path recomputes normals exactly; the perf lab renders both so the difference can be looked
75
+ * at rather than assumed.
76
+ */
77
+ fn deformNormal(n: vec3<f32>, py: f32, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32>) -> vec3<f32> {
78
+ let u = clamp(py * d0.w, 0.0, 1.0);
79
+ let rs = max(d0.y * (1.0 + d0.z * (1.0 - 2.0 * u)), 1e-4);
80
+ var v = vec3(n.x / rs, n.y / max(d0.x, 1e-4), n.z / rs);
81
+ let a = d1.z * u + d2.x;
82
+ let ca = cos(a); let sa = sin(a);
83
+ return normalize(vec3(v.x * ca - v.z * sa, v.y, v.x * sa + v.z * ca));
84
+ }
31
85
  @vertex fn vs(@location(0) p: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) col: vec3<f32>, @builtin(instance_index) ii: u32) -> VO {
32
- let m = models[ii];
33
- var world = (m * vec4(p, 1.0)).xyz;
86
+ let inst = models[ii];
87
+ let m = inst.m;
88
+ let lp = deformLocal(p, inst.d0, inst.d1, inst.d2);
89
+ let ln = deformNormal(nrm, p.y, inst.d0, inst.d1, inst.d2);
90
+ var world = (m * vec4(lp, 1.0)).xyz;
34
91
  let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
35
92
  let sc = length(m[0].xyz);
36
- world = sway(p, world, upv, sc);
37
- var o: VO; o.pos = u.mvp * vec4(world, 1.0); o.world = world; o.n = (m * vec4(nrm, 0.0)).xyz; o.c = col; o.l = p; return o;
93
+ world = sway(lp, world, upv, sc);
94
+ var o: VO; o.pos = u.mvp * vec4(world, 1.0); o.world = world; o.n = (m * vec4(ln, 0.0)).xyz;
95
+ o.c = col * inst.d2.y; o.l = lp; return o;
38
96
  }
39
97
  // same fixed directional FILL as the terrain cubes (gpuShadow.fixedFill) so plants/rocks read the same 3D shape,
40
98
  // day or night, under one shared light. up.w > 0.5 signals a valid local-up was supplied (else fall back to flat).
@@ -124,13 +182,40 @@ fn hsvShift(c: vec3<f32>, hue: f32, sat: f32, val: f32) -> vec3<f32> {
124
182
  const OCCLUDER = /* wgsl */ `
125
183
  struct OU { lightVP: mat4x4<f32>, wind: vec4<f32> };
126
184
  @group(0) @binding(0) var<uniform> o: OU;
127
- @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
185
+ struct Inst { m: mat4x4<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32> };
186
+ @group(0) @binding(1) var<storage,read> models: array<Inst>;
187
+ // The occluder must apply the identical DEFORM as well as the identical sway. A deformed plant whose
188
+ // shadow is cast from its undeformed silhouette is the same detachment bug as swaying leaves over a
189
+ // still shadow, and just as visible.
190
+ fn deformLocal(p: vec3<f32>, d0: vec4<f32>, d1: vec4<f32>, d2: vec4<f32>) -> vec3<f32> {
191
+ let H = 1.0 / max(d0.w, 1e-6);
192
+ let u = clamp(p.y * d0.w, 0.0, 1.0);
193
+ var x = p.x; var y = p.y; var z = p.z;
194
+ let rs = d0.y * (1.0 + d0.z * (1.0 - 2.0 * u));
195
+ x = x * rs; z = z * rs;
196
+ let a = d1.z * u;
197
+ let ca = cos(a); let sa = sin(a);
198
+ let tx = x * ca - z * sa; let tz = x * sa + z * ca;
199
+ x = tx; z = tz;
200
+ y = y * d0.x;
201
+ if (d1.w != 0.0) {
202
+ let r = sqrt(x * x + z * z) * d2.z;
203
+ y = y - d1.w * H * d0.x * r * r * u;
204
+ }
205
+ let dd = d1.x * H * d0.x * u * u;
206
+ x = x + cos(d1.y) * dd;
207
+ z = z + sin(d1.y) * dd;
208
+ let cy = cos(d2.x); let sy = sin(d2.x);
209
+ return vec3(x * cy - z * sy, y, x * sy + z * cy);
210
+ }
128
211
  @vertex fn vs(@location(0) p: vec3<f32>, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
129
- let m = models[ii];
130
- var world = (m * vec4(p, 1.0)).xyz;
212
+ let inst = models[ii];
213
+ let m = inst.m;
214
+ let dp = deformLocal(p, inst.d0, inst.d1, inst.d2);
215
+ var world = (m * vec4(dp, 1.0)).xyz;
131
216
  if (o.wind.z > 0.0001) {
132
217
  let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
133
- let h = max(p.y, 0.0);
218
+ let h = max(dp.y, 0.0);
134
219
  let stiff = pow(min(h * 0.22, 1.0), 1.6);
135
220
  let ph = o.wind.w + dot(world.xz, vec2(0.13, 0.17));
136
221
  let a = o.wind.z * stiff * length(m[0].xyz);
@@ -140,6 +225,8 @@ struct OU { lightVP: mat4x4<f32>, wind: vec4<f32> };
140
225
  }
141
226
  return o.lightVP * vec4(world, 1.0);
142
227
  }`;
228
+ /** Floats per instance in the storage buffer: mat4 + d0 + d1 + d2. */
229
+ const INST_FLOATS = 28;
143
230
  export class GpuPropsRenderer {
144
231
  device;
145
232
  pipe;
@@ -148,6 +235,8 @@ export class GpuPropsRenderer {
148
235
  lightBuf;
149
236
  models = [];
150
237
  instBufs = [];
238
+ /** Allocated capacity in instances, so a per-frame LOD re-bucket reuses the buffer. */
239
+ caps = [];
151
240
  bgs = [];
152
241
  occBGs = [];
153
242
  counts = [];
@@ -270,54 +359,76 @@ export class GpuPropsRenderer {
270
359
  colB: mk(m.colors, U.VERTEX | U.COPY_DST),
271
360
  idxB: mk(m.indices, U.INDEX | U.COPY_DST),
272
361
  idxCount: m.indices.length,
362
+ extent: measureExtent(m.positions),
273
363
  }));
364
+ this.caps = meshes.map(() => 0);
274
365
  this.instBufs = meshes.map(() => null);
275
366
  this.bgs = meshes.map(() => null);
276
367
  this.occBGs = meshes.map(() => null);
277
368
  this.counts = meshes.map(() => 0);
278
369
  }
279
- /** Group instances by model and upload per-model transform buffers. */
370
+ /**
371
+ * Group instances by model and upload the per-model instance buffers.
372
+ *
373
+ * Safe to call every frame: buffers are REUSED whenever the new count fits the allocated capacity,
374
+ * and capacity grows with headroom. That matters because distance-bucketed LOD means an instance
375
+ * changes which model it belongs to as the camera moves, so the grouping is not a one-time setup —
376
+ * re-allocating a megabyte of storage buffers per frame would cost more than the LOD saves.
377
+ */
280
378
  setInstances(instances) {
281
379
  const dev = this.device;
282
- const byModel = this.models.map(() => []);
283
- const data = this.models.map(() => []);
380
+ const n = this.models.length;
381
+ const counts = new Uint32Array(n);
382
+ for (const it of instances)
383
+ if (it.model >= 0 && it.model < n)
384
+ counts[it.model]++;
385
+ const cursors = new Uint32Array(n);
386
+ const arrays = new Array(n);
387
+ for (let m = 0; m < n; m++)
388
+ arrays[m] = counts[m] ? new Float32Array(counts[m] * INST_FLOATS) : null;
284
389
  for (const it of instances) {
285
- if (it.model < 0 || it.model >= this.models.length)
390
+ if (it.model < 0 || it.model >= n)
286
391
  continue;
287
- byModel[it.model].push(0);
288
- for (let i = 0; i < 16; i++)
289
- data[it.model].push(it.mat[i]);
392
+ const arr = arrays[it.model];
393
+ const o = cursors[it.model]++ * INST_FLOATS;
394
+ arr.set(it.mat.subarray(0, 16), o);
395
+ writeDeform(arr, o + 16, it.deform, this.models[it.model].extent);
290
396
  }
291
- for (let m = 0; m < this.models.length; m++) {
292
- this.instBufs[m]?.destroy();
293
- const arr = new Float32Array(data[m]);
294
- this.counts[m] = byModel[m].length;
295
- if (arr.length === 0) {
296
- this.instBufs[m] = null;
297
- this.bgs[m] = null;
298
- this.occBGs[m] = null;
397
+ for (let m = 0; m < n; m++) {
398
+ const arr = arrays[m];
399
+ this.counts[m] = counts[m];
400
+ if (!arr) {
401
+ // keep the buffer allocated — an empty LOD bucket this frame is usually full the next one
402
+ if (!this.instBufs[m]) {
403
+ this.bgs[m] = null;
404
+ this.occBGs[m] = null;
405
+ }
299
406
  continue;
300
407
  }
301
- const b = dev.createBuffer({
302
- size: arr.byteLength,
303
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
304
- });
305
- dev.queue.writeBuffer(b, 0, arr);
306
- this.instBufs[m] = b;
307
- this.bgs[m] = dev.createBindGroup({
308
- layout: this.pipe.getBindGroupLayout(0),
309
- entries: [
310
- { binding: 0, resource: { buffer: this.uBuf } },
311
- { binding: 1, resource: { buffer: b } },
312
- ],
313
- });
314
- this.occBGs[m] = dev.createBindGroup({
315
- layout: this.occPipe.getBindGroupLayout(0),
316
- entries: [
317
- { binding: 0, resource: { buffer: this.lightBuf } },
318
- { binding: 1, resource: { buffer: b } },
319
- ],
320
- });
408
+ if (!this.instBufs[m] || counts[m] > this.caps[m]) {
409
+ this.instBufs[m]?.destroy();
410
+ const cap = Math.max(16, Math.ceil(counts[m] * 1.5));
411
+ this.instBufs[m] = dev.createBuffer({
412
+ size: cap * INST_FLOATS * 4,
413
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
414
+ });
415
+ this.caps[m] = cap;
416
+ this.bgs[m] = dev.createBindGroup({
417
+ layout: this.pipe.getBindGroupLayout(0),
418
+ entries: [
419
+ { binding: 0, resource: { buffer: this.uBuf } },
420
+ { binding: 1, resource: { buffer: this.instBufs[m] } },
421
+ ],
422
+ });
423
+ this.occBGs[m] = dev.createBindGroup({
424
+ layout: this.occPipe.getBindGroupLayout(0),
425
+ entries: [
426
+ { binding: 0, resource: { buffer: this.lightBuf } },
427
+ { binding: 1, resource: { buffer: this.instBufs[m] } },
428
+ ],
429
+ });
430
+ }
431
+ dev.queue.writeBuffer(this.instBufs[m], 0, arr);
321
432
  }
322
433
  }
323
434
  /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
@@ -401,6 +512,44 @@ export class GpuPropsRenderer {
401
512
  rp.end();
402
513
  }
403
514
  }
515
+ /** Source height and half-width of a model, reciprocated so the shader multiplies rather than divides. */
516
+ function measureExtent(pos) {
517
+ let maxY = -Infinity;
518
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity;
519
+ for (let v = 0; v + 2 < pos.length; v += 3) {
520
+ if (pos[v + 1] > maxY)
521
+ maxY = pos[v + 1];
522
+ if (pos[v] < minX)
523
+ minX = pos[v];
524
+ if (pos[v] > maxX)
525
+ maxX = pos[v];
526
+ if (pos[v + 2] < minZ)
527
+ minZ = pos[v + 2];
528
+ if (pos[v + 2] > maxZ)
529
+ maxZ = pos[v + 2];
530
+ }
531
+ // The cook normalises every prop to base-at-y=0, so maxY IS the height. A model that violates that
532
+ // still renders — it just gets a `u` ramp that does not reach 1, i.e. a milder deform, never a
533
+ // broken one.
534
+ const h = Math.max(1e-6, maxY);
535
+ const halfW = Math.max(1e-6, Math.max(maxX - minX, maxZ - minZ) * 0.5);
536
+ return { invH: 1 / h, invHalfW: 1 / halfW };
537
+ }
538
+ /** Pack a PropDeform into d0/d1/d2. An absent deform writes the exact identity. */
539
+ function writeDeform(out, o, d, e) {
540
+ out[o] = d?.height ?? 1;
541
+ out[o + 1] = d?.girth ?? 1;
542
+ out[o + 2] = d?.taper ?? 0;
543
+ out[o + 3] = e.invH;
544
+ out[o + 4] = d?.bend ?? 0;
545
+ out[o + 5] = d?.bendDir ?? 0;
546
+ out[o + 6] = d?.twist ?? 0;
547
+ out[o + 7] = d?.droop ?? 0;
548
+ out[o + 8] = d?.yaw ?? 0;
549
+ out[o + 9] = d?.valueJitter ?? 1;
550
+ out[o + 10] = e.invHalfW;
551
+ out[o + 11] = 0;
552
+ }
404
553
  // column-major identity mat4, used for lightVP when shadow-receive is off
405
554
  const IDENTITY4 = new Float32Array([
406
555
  1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.7.9",
3
+ "version": "0.7.10",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {