ugly-game 0.5.7 → 0.5.9

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.
@@ -52,9 +52,21 @@ export declare class GpuShadowRenderer {
52
52
  camVisible: number;
53
53
  lightVisible: number;
54
54
  };
55
+ private hizPipe;
56
+ private hizBGL;
57
+ private hizTex;
58
+ private hizView;
59
+ private hizParam;
60
+ private hizW;
61
+ private hizH;
62
+ private hizReady;
63
+ private occView;
55
64
  constructor(device: GPUDevice, format: GPUTextureFormat);
56
65
  private makeCullBuf;
66
+ private cullBG;
57
67
  private makeCullResources;
68
+ /** (Re)create the coarse Hi-Z for a `w×h` depth target and rebind the cull groups to it. Idempotent per size. */
69
+ private ensureHiZ;
58
70
  private scratch;
59
71
  private fill;
60
72
  private makeBindGroups;
@@ -65,10 +77,16 @@ export declare class GpuShadowRenderer {
65
77
  updateInstances(instances: CubeInstance[]): void;
66
78
  /** Translucent overlay cubes drawn over the opaque scene each frame; `emissive` carries per-cube alpha. */
67
79
  updateOverlay(instances: CubeInstance[]): void;
68
- /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
80
+ /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
69
81
  private writeCull;
70
- /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
71
- frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean): void;
82
+ /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
83
+ * its size) to enable Hi-Z occlusion culling on the camera pass the renderer rebuilds a coarse Hi-Z from that
84
+ * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
85
+ frame(enc: GPUCommandEncoder, colorView: GPUTextureView, sceneDepth: GPUTextureView, camVP: Float32Array, lightVP: Float32Array, sunDir: [number, number, number], sunCol: [number, number, number], ambient: number, load?: boolean, occlusion?: {
86
+ depth: GPUTextureView;
87
+ w: number;
88
+ h: number;
89
+ }): void;
72
90
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
73
91
  * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
74
92
  readCullStats(): Promise<{
package/dist/gpuShadow.js CHANGED
@@ -77,14 +77,25 @@ fn hsv(hh: f32) -> vec3<f32> {
77
77
  // instance quaternion). This is exact for any size/aspect/rotation — critical because a bounding SPHERE from the
78
78
  // centre wrongly culls a big flat cube (a ground plane whose centre sits far behind the camera while the ground
79
79
  // underfoot is plainly in view). Mirrors gpuDriven.ts; validated against a CPU box-vs-plane oracle.
80
+ // OCCLUSION CULLING (Hi-Z). After the frustum test, an in-frustum cube is ALSO dropped if it's fully hidden behind
81
+ // nearer geometry. `hiz` is a coarse MAX-depth buffer (each texel = the farthest visible NDC depth over its block)
82
+ // built from the PREVIOUS frame's depth. A cube is occluded iff its nearest corner is behind the farthest occluder
83
+ // across its whole screen footprint. Using last frame's depth + a coarse buffer makes this HOLE-FREE by
84
+ // construction: a newly-disoccluded area has far/cleared depth so nothing there is culled — the worst case is
85
+ // under-culling for one frame, never a false cull. occ.x toggles it (camera pass only; the shadow pass never
86
+ // occludes — an off-screen occluder still casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
80
87
  const CULL_WGSL = /* wgsl */ `
81
88
  struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32> };
82
89
  struct Args { vc: u32, ic: atomic<u32>, fv: u32, fi: u32 };
83
- struct CullCam { planes: array<vec4<f32>, 6>, cnt: vec4<f32> }; // cnt.x = instance count
90
+ struct CullCam {
91
+ planes: array<vec4<f32>, 6>, cnt: vec4<f32>, // cnt.x = instance count
92
+ camVP: mat4x4<f32>, occ: vec4<f32>, // occ.x = occlusion on · occ.yz = hiz w,h · occ.w = downscale
93
+ };
84
94
  @group(0) @binding(0) var<storage, read> insts: array<Inst>;
85
95
  @group(0) @binding(1) var<storage, read_write> args: Args;
86
96
  @group(0) @binding(2) var<storage, read_write> visible: array<u32>;
87
97
  @group(0) @binding(3) var<uniform> cc: CullCam;
98
+ @group(0) @binding(4) var hiz: texture_2d<f32>;
88
99
  fn qr(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> { let t = 2.0 * cross(q.xyz, v); return v + q.w * t + cross(q.xyz, t); }
89
100
  @compute @workgroup_size(${CULL_WG})
90
101
  fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
@@ -101,10 +112,65 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
101
112
  let e = abs(dot(pl.xyz, bx)) + abs(dot(pl.xyz, by)) + abs(dot(pl.xyz, bz)); // box half-width along the normal
102
113
  if (dot(pl.xyz, c) + pl.w < -e) { return; }
103
114
  }
115
+ // ── Hi-Z occlusion (camera pass only) ──
116
+ if (cc.occ.x > 0.5) {
117
+ var mn = vec2<f32>(1e9, 1e9);
118
+ var mx = vec2<f32>(-1e9, -1e9);
119
+ var nearZ = 1e9;
120
+ var ok = true;
121
+ for (var ci = 0u; ci < 8u; ci++) {
122
+ let s = vec3<f32>(select(-1.0, 1.0, (ci & 1u) != 0u), select(-1.0, 1.0, (ci & 2u) != 0u), select(-1.0, 1.0, (ci & 4u) != 0u));
123
+ let corner = c + bx * s.x + by * s.y + bz * s.z;
124
+ let clip = cc.camVP * vec4<f32>(corner, 1.0);
125
+ if (clip.w <= 0.0) { ok = false; break; } // a corner at/behind the eye → don't occlusion-cull (keep)
126
+ let ndc = clip.xyz / clip.w;
127
+ let uv = ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5);
128
+ mn = min(mn, uv); mx = max(mx, uv);
129
+ nearZ = min(nearZ, ndc.z);
130
+ }
131
+ if (ok && nearZ >= 0.0) {
132
+ let dim = vec2<f32>(cc.occ.y, cc.occ.z);
133
+ let t0 = vec2<i32>(floor(clamp(mn, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
134
+ let t1 = vec2<i32>(floor(clamp(mx, vec2<f32>(0.0), vec2<f32>(1.0)) * dim));
135
+ // only query a bounded footprint; a huge on-screen box just isn't occlusion-tested (kept)
136
+ if (t1.x - t0.x <= 8 && t1.y - t0.y <= 8) {
137
+ var farOccluder = 0.0; // MAX over the footprint of the coarse max-depth
138
+ for (var y = t0.y; y <= t1.y; y++) {
139
+ for (var x = t0.x; x <= t1.x; x++) {
140
+ farOccluder = max(farOccluder, textureLoad(hiz, vec2<i32>(x, y), 0).r);
141
+ }
142
+ }
143
+ if (nearZ > farOccluder) { return; } // behind the farthest occluder everywhere → occluded
144
+ }
145
+ }
146
+ }
104
147
  let slot = atomicAdd(&args.ic, 1u);
105
148
  visible[slot] = i;
106
149
  }
107
150
  `;
151
+ // Hi-Z BUILD: max-reduce the previous frame's depth (a sampleable depth texture) into a coarse R32float buffer —
152
+ // each output texel = the FARTHEST (max) NDC depth over its `down`×`down` source block, so "cube behind this" is a
153
+ // safe occlusion test. depth24plus reads as a depth texture; textureLoad returns the stored [0,1] depth.
154
+ const HIZ_WGSL = /* wgsl */ `
155
+ @group(0) @binding(0) var srcDepth: texture_depth_2d;
156
+ @group(0) @binding(1) var dst: texture_storage_2d<r32float, write>;
157
+ struct P { dim: vec4<u32> }; // dim.xy = dst size, dim.zw = src size
158
+ @group(0) @binding(2) var<uniform> p: P;
159
+ @compute @workgroup_size(8, 8)
160
+ fn build(@builtin(global_invocation_id) gid: vec3<u32>) {
161
+ if (gid.x >= p.dim.x || gid.y >= p.dim.y) { return; }
162
+ let down = 8u;
163
+ var mx = 0.0;
164
+ for (var dy = 0u; dy < down; dy++) {
165
+ for (var dx = 0u; dx < down; dx++) {
166
+ let sx = gid.x * down + dx; let sy = gid.y * down + dy;
167
+ if (sx < p.dim.z && sy < p.dim.w) { mx = max(mx, textureLoad(srcDepth, vec2<i32>(i32(sx), i32(sy)), 0)); }
168
+ }
169
+ }
170
+ textureStore(dst, vec2<i32>(i32(gid.x), i32(gid.y)), vec4<f32>(mx, 0.0, 0.0, 1.0));
171
+ }
172
+ `;
173
+ const HIZ_DOWN = 8; // source pixels per Hi-Z texel (coarse ⇒ conservative, cheap)
108
174
  const SHADOW_WGSL = COMMON_WGSL + /* wgsl */ `
109
175
  @group(0) @binding(2) var<storage, read> vis: array<u32>;
110
176
  @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
@@ -232,6 +298,16 @@ export class GpuShadowRenderer {
232
298
  statsBuf; // readback: [camVisible, lightVisible] for the IR/tests
233
299
  /** Last frame's visible counts (main-pass camera cull, shadow-pass light cull) + total — for IR/telemetry. */
234
300
  lastCull = { total: 0, camVisible: 0, lightVisible: 0 };
301
+ // Hi-Z OCCLUSION: a coarse max-depth buffer rebuilt from each frame's depth, sampled by next frame's camera cull.
302
+ hizPipe;
303
+ hizBGL;
304
+ hizTex;
305
+ hizView;
306
+ hizParam;
307
+ hizW = 0;
308
+ hizH = 0;
309
+ hizReady = false; // a Hi-Z from a prior frame exists → safe to occlusion-test (false on frame 1 + resize)
310
+ occView; // the texture bound at cull binding 4 (a 1×1 dummy until occlusion is used)
235
311
  constructor(device, format) {
236
312
  this.device = device;
237
313
  this.camBuf = device.createBuffer({ size: this.cam.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
@@ -293,39 +369,71 @@ export class GpuShadowRenderer {
293
369
  { binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'read-only-storage' } }, // insts
294
370
  { binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // args (atomic ic)
295
371
  { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'storage' } }, // visible list
296
- { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count
372
+ { binding: 3, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } }, // planes + count + camVP + occ
373
+ { binding: 4, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'unfilterable-float' } }, // Hi-Z (dummy until occlusion is used)
297
374
  ] });
298
375
  this.cullPipe = device.createComputePipeline({
299
376
  layout: device.createPipelineLayout({ bindGroupLayouts: [this.cullBGL] }),
300
377
  compute: { module: cullMod, entryPoint: 'cull' },
301
378
  });
379
+ // Hi-Z occlusion build pipeline + a 1×1 dummy Hi-Z (bound when occlusion is off, never sampled)
380
+ const hizMod = device.createShaderModule({ code: HIZ_WGSL });
381
+ this.hizBGL = device.createBindGroupLayout({ entries: [
382
+ { binding: 0, visibility: GPUShaderStage.COMPUTE, texture: { sampleType: 'depth' } },
383
+ { binding: 1, visibility: GPUShaderStage.COMPUTE, storageTexture: { access: 'write-only', format: 'r32float' } },
384
+ { binding: 2, visibility: GPUShaderStage.COMPUTE, buffer: { type: 'uniform' } },
385
+ ] });
386
+ this.hizPipe = device.createComputePipeline({
387
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.hizBGL] }),
388
+ compute: { module: hizMod, entryPoint: 'build' },
389
+ });
390
+ this.hizParam = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
391
+ const dummy = device.createTexture({ size: [1, 1], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
392
+ this.occView = dummy.createView();
302
393
  }
303
394
  // One frustum's cull resources: indirect args (drawIndirect), a compacted visible-index list sized to the
304
- // instance capacity, and a small uniform holding the 6 planes + the live instance count.
395
+ // instance capacity, and a uniform holding the 6 planes + instance count + camVP + occlusion params.
305
396
  makeCullBuf() {
306
397
  const d = this.device;
307
398
  const args = d.createBuffer({ size: 4 * 4, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.INDIRECT | GPUBufferUsage.COPY_DST | GPUBufferUsage.COPY_SRC });
308
399
  const vis = d.createBuffer({ size: Math.max(1, this.cap) * 4, usage: GPUBufferUsage.STORAGE });
309
- const cull = d.createBuffer({ size: 6 * 16 + 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
310
- const bg = d.createBindGroup({ layout: this.cullBGL, entries: [
400
+ const cull = d.createBuffer({ size: 6 * 16 + 16 + 64 + 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); // planes+cnt+camVP+occ = 192
401
+ return { args, vis, cull };
402
+ }
403
+ cullBG(args, vis, cull) {
404
+ return this.device.createBindGroup({ layout: this.cullBGL, entries: [
311
405
  { binding: 0, resource: { buffer: this.instBuf } }, { binding: 1, resource: { buffer: args } },
312
- { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } }
406
+ { binding: 2, resource: { buffer: vis } }, { binding: 3, resource: { buffer: cull } },
407
+ { binding: 4, resource: this.occView }
313
408
  ] });
314
- return { args, vis, cull, bg };
315
409
  }
316
410
  makeCullResources() {
317
411
  const cam = this.makeCullBuf();
318
412
  this.camArgs = cam.args;
319
413
  this.camVis = cam.vis;
320
414
  this.camCull = cam.cull;
321
- this.camCullBG = cam.bg;
322
415
  const lit = this.makeCullBuf();
323
416
  this.lightArgs = lit.args;
324
417
  this.lightVis = lit.vis;
325
418
  this.lightCull = lit.cull;
326
- this.lightCullBG = lit.bg;
419
+ this.camCullBG = this.cullBG(this.camArgs, this.camVis, this.camCull);
420
+ this.lightCullBG = this.cullBG(this.lightArgs, this.lightVis, this.lightCull);
327
421
  this.statsBuf = this.device.createBuffer({ size: 4 * 4 * 2, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
328
422
  }
423
+ /** (Re)create the coarse Hi-Z for a `w×h` depth target and rebind the cull groups to it. Idempotent per size. */
424
+ ensureHiZ(w, h) {
425
+ const hw = Math.max(1, Math.ceil(w / HIZ_DOWN)), hh = Math.max(1, Math.ceil(h / HIZ_DOWN));
426
+ if (this.hizTex && this.hizW === hw && this.hizH === hh)
427
+ return;
428
+ this.hizW = hw;
429
+ this.hizH = hh;
430
+ this.hizReady = false; // fresh texture is uninitialized → skip occlusion until it's built once
431
+ this.hizTex = this.device.createTexture({ size: [hw, hh], format: 'r32float', usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.STORAGE_BINDING });
432
+ this.hizView = this.hizTex.createView();
433
+ this.occView = this.hizView; // the cull now samples the real Hi-Z
434
+ this.camCullBG = this.cullBG(this.camArgs, this.camVis, this.camCull);
435
+ this.lightCullBG = this.cullBG(this.lightArgs, this.lightVis, this.lightCull);
436
+ }
329
437
  scratch = new Float32Array(0);
330
438
  fill(scratch, instances, n) {
331
439
  for (let i = 0; i < n; i++) {
@@ -388,10 +496,10 @@ export class GpuShadowRenderer {
388
496
  this.device.queue.writeBuffer(this.overlayBuf, 0, this.overlayScratch, 0, n * 12);
389
497
  this.overlayCount = n;
390
498
  }
391
- /** Write a frustum's cull uniform: 6 Gribb-Hartmann planes from `vp` + the live instance count. */
392
- writeCull(buf, vp) {
499
+ /** Write a frustum's cull uniform: 6 planes + count + this frustum's VP + occlusion params (occ on the camera pass). */
500
+ writeCull(buf, vp, occlude) {
393
501
  const planes = frustumPlanes(vp);
394
- const u = new Float32Array(6 * 4 + 4);
502
+ const u = new Float32Array(48); // planes[24] + cnt[4] + camVP[16] + occ[4]
395
503
  for (let p = 0; p < 6; p++) {
396
504
  u[p * 4] = planes[p][0];
397
505
  u[p * 4 + 1] = planes[p][1];
@@ -399,10 +507,17 @@ export class GpuShadowRenderer {
399
507
  u[p * 4 + 3] = planes[p][3];
400
508
  }
401
509
  u[24] = this.count;
510
+ u.set(vp, 28); // camVP
511
+ u[44] = occlude ? 1 : 0;
512
+ u[45] = this.hizW;
513
+ u[46] = this.hizH;
514
+ u[47] = HIZ_DOWN;
402
515
  this.device.queue.writeBuffer(buf, 0, u);
403
516
  }
404
- /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. */
405
- frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false) {
517
+ /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
518
+ * its size) to enable Hi-Z occlusion culling on the camera pass — the renderer rebuilds a coarse Hi-Z from that
519
+ * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
520
+ frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false, occlusion) {
406
521
  this.cam.set(camVP, 0);
407
522
  this.cam.set(lightVP, 16);
408
523
  this.cam[32] = sunDir[0];
@@ -414,14 +529,17 @@ export class GpuShadowRenderer {
414
529
  this.cam[38] = sunCol[2];
415
530
  this.cam[39] = SHADOW_SIZE;
416
531
  this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
532
+ if (occlusion)
533
+ this.ensureHiZ(occlusion.w, occlusion.h);
534
+ const occludeNow = !!occlusion && this.hizReady; // only once a Hi-Z from a prior frame exists (never on frame 1/resize)
417
535
  // GPU-DRIVEN CULL: reset each frustum's indirect args ([vertexCount=36, instanceCount=0, 0, 0]), then compute-cull
418
- // — main pass vs the camera frustum, shadow pass vs the light frustum. Each dispatch atomically compacts its
419
- // survivors into a visible list + counts them in instanceCount, which the matching drawIndirect then consumes.
536
+ // — main pass vs the camera frustum (+ Hi-Z occlusion), shadow pass vs the light frustum. Each dispatch atomically
537
+ // compacts its survivors into a visible list + counts them in instanceCount, which the matching drawIndirect consumes.
420
538
  const resetArgs = new Uint32Array([36, 0, 0, 0]);
421
539
  this.device.queue.writeBuffer(this.camArgs, 0, resetArgs);
422
540
  this.device.queue.writeBuffer(this.lightArgs, 0, resetArgs);
423
- this.writeCull(this.camCull, camVP);
424
- this.writeCull(this.lightCull, lightVP);
541
+ this.writeCull(this.camCull, camVP, occludeNow);
542
+ this.writeCull(this.lightCull, lightVP, false);
425
543
  if (this.count > 0) {
426
544
  const cp = enc.beginComputePass();
427
545
  cp.setPipeline(this.cullPipe);
@@ -439,7 +557,7 @@ export class GpuShadowRenderer {
439
557
  sp.setBindGroup(0, this.shadowBG);
440
558
  sp.drawIndirect(this.lightArgs, 0);
441
559
  sp.end();
442
- // Pass 2: camera, shadow-sampled (camera-frustum-culled).
560
+ // Pass 2: camera, shadow-sampled (camera-frustum + Hi-Z-occlusion-culled).
443
561
  const mp = enc.beginRenderPass({
444
562
  colorAttachments: [{ view: colorView, ...(load ? { loadOp: 'load' } : { clearValue: { r: 0.04, g: 0.05, b: 0.07, a: 1 }, loadOp: 'clear' }), storeOp: 'store' }],
445
563
  depthStencilAttachment: { view: sceneDepth, ...(load ? { depthLoadOp: 'load' } : { depthClearValue: 1.0, depthLoadOp: 'clear' }), depthStoreOp: 'store' }
@@ -453,6 +571,19 @@ export class GpuShadowRenderer {
453
571
  mp.draw(36, this.overlayCount);
454
572
  } // translucent overlay last (uncapped, no cull)
455
573
  mp.end();
574
+ // Rebuild the coarse Hi-Z from the depth just written → next frame's cull uses it.
575
+ if (occlusion) {
576
+ this.device.queue.writeBuffer(this.hizParam, 0, new Uint32Array([this.hizW, this.hizH, occlusion.w, occlusion.h]));
577
+ const bg = this.device.createBindGroup({ layout: this.hizBGL, entries: [
578
+ { binding: 0, resource: occlusion.depth }, { binding: 1, resource: this.hizView }, { binding: 2, resource: { buffer: this.hizParam } }
579
+ ] });
580
+ const hp = enc.beginComputePass();
581
+ hp.setPipeline(this.hizPipe);
582
+ hp.setBindGroup(0, bg);
583
+ hp.dispatchWorkgroups(Math.ceil(this.hizW / 8), Math.ceil(this.hizH / 8));
584
+ hp.end();
585
+ this.hizReady = true;
586
+ }
456
587
  this.lastCull.total = this.count;
457
588
  }
458
589
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
@@ -6,6 +6,9 @@ interface DOStorage {
6
6
  get<T>(key: string): Promise<T | undefined>;
7
7
  put<T>(key: string, value: T): Promise<void>;
8
8
  delete(key: string): Promise<void>;
9
+ list<T>(options?: {
10
+ prefix?: string;
11
+ }): Promise<Map<string, T>>;
9
12
  }
10
13
  interface DOState {
11
14
  storage: DOStorage;
@@ -22,8 +25,11 @@ export declare class RoomCore {
22
25
  private seq;
23
26
  constructor(state: DOState, opts?: RoomOptions);
24
27
  fetch(request: Request): Promise<unknown>;
25
- /** The app DO forwards its webSocketMessage here. */
26
- webSocketMessage(ws: WS, message: string | ArrayBuffer): void;
28
+ /** The app DO forwards its webSocketMessage here. Async so relayed edits can be durably accumulated. */
29
+ webSocketMessage(ws: WS, message: string | ArrayBuffer): Promise<void>;
30
+ /** LWW-accumulate a relayed keyed edit into DO storage, so a later joiner reconstructs the world. Keeps the
31
+ * higher (stamp, by) — the exact rule the free-roam client's LwwStore uses, so server + clients converge. */
32
+ private accumulateEdit;
27
33
  webSocketClose(ws: WS): void;
28
34
  webSocketError(ws: WS): void;
29
35
  private json;
@@ -17,6 +17,13 @@
17
17
  // · `repin` → lone client re-pins the shared-clock epoch
18
18
  // Snapshot bytes are stored chunked (PUT/GET) for join/resync + save. WebSocket
19
19
  // HIBERNATION (state.acceptWebSocket) means no duration billed while a room idles.
20
+ //
21
+ // PERSISTENT KEYED WORLD (free-roam / all-mine-sky): a `relay` whose payload carries an `e = {key, val, stamp,
22
+ // by}` is ALSO accumulated server-side, last-write-wins by (stamp, by), under `e:<key>`. GET `edits` returns the
23
+ // accumulated set so a joiner reconstructs the world — even into an empty room. Persisting from the WS handler
24
+ // (not a concurrent HTTP PUT) is REQUIRED: an HTTP storage write to a DO that holds a live hibernatable socket
25
+ // silently no-ops, whereas a write inside webSocketMessage commits. Relays without an `e.key` (e.g. cogsworth's
26
+ // lockstep commands) are untouched — accumulation is opt-in by payload shape, so RoomCore stays game-agnostic.
20
27
  // ─────────────────────────────────────────────────────────────────────────────
21
28
  const CHUNK = 96 * 1024; // DO value cap is ~128KB; chunk snapshots under it
22
29
  const KEYS = { epoch: 'epoch', chunks: 'chunks', bytes: 'bytes', tick: 'tick' };
@@ -45,6 +52,18 @@ export class RoomCore {
45
52
  pair[1].send(JSON.stringify({ t: 'hello', peers: this.state.getWebSockets().length, epoch, now: Date.now() }));
46
53
  return new Response(null, { status: 101, webSocket: pair[0] });
47
54
  }
55
+ // ── accumulated keyed edits (free-roam join sync): the LWW deltas persisted from the relay stream ──
56
+ if (path === 'edits') {
57
+ const m = await this.state.storage.list({ prefix: 'e:' });
58
+ return new Response(JSON.stringify([...m.values()]), {
59
+ status: 200,
60
+ headers: {
61
+ 'content-type': 'application/json',
62
+ 'cache-control': 'no-store',
63
+ 'x-ugly-no-spa-fallback': '1',
64
+ },
65
+ });
66
+ }
48
67
  // ── snapshot SAVE (join/resync + persistence): body = opaque bytes, stored chunked ──
49
68
  if (request.method === 'PUT' || request.method === 'POST') {
50
69
  const body = new Uint8Array(await request.arrayBuffer());
@@ -87,8 +106,8 @@ export class RoomCore {
87
106
  },
88
107
  });
89
108
  }
90
- /** The app DO forwards its webSocketMessage here. */
91
- webSocketMessage(ws, message) {
109
+ /** The app DO forwards its webSocketMessage here. Async so relayed edits can be durably accumulated. */
110
+ async webSocketMessage(ws, message) {
92
111
  let m = null;
93
112
  try {
94
113
  m = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message));
@@ -104,7 +123,7 @@ export class RoomCore {
104
123
  }
105
124
  if (m.t === 'repin' && typeof m.tick === 'number' && this.state.getWebSockets().length === 1) {
106
125
  const epoch = Date.now() - m.tick * (this.opts.tickMs ?? 0);
107
- void this.state.storage.put(KEYS.epoch, epoch);
126
+ await this.state.storage.put(KEYS.epoch, epoch);
108
127
  ws.send(JSON.stringify({ t: 'epoch', epoch, now: Date.now() }));
109
128
  return;
110
129
  }
@@ -120,6 +139,28 @@ export class RoomCore {
120
139
  const stamped = JSON.stringify({ ...m, seq: ++this.seq });
121
140
  for (const peer of this.state.getWebSockets())
122
141
  peer.send(stamped);
142
+ // free-roam persistence: a relay carrying a keyed edit is accumulated LWW so it outlives the session (fanout
143
+ // first — this must never delay the relay). Opt-in by payload shape → lockstep relays (no e.key) are untouched.
144
+ if (m.t === 'relay')
145
+ await this.accumulateEdit(m.e);
146
+ }
147
+ /** LWW-accumulate a relayed keyed edit into DO storage, so a later joiner reconstructs the world. Keeps the
148
+ * higher (stamp, by) — the exact rule the free-roam client's LwwStore uses, so server + clients converge. */
149
+ async accumulateEdit(e) {
150
+ if (!e || typeof e !== 'object')
151
+ return;
152
+ const ed = e;
153
+ if (typeof ed.key !== 'number' && typeof ed.key !== 'string')
154
+ return;
155
+ const k = `e:${ed.key}`;
156
+ const es = typeof ed.stamp === 'number' ? ed.stamp : 0;
157
+ const cur = await this.state.storage.get(k);
158
+ if (cur) {
159
+ const cs = typeof cur.stamp === 'number' ? cur.stamp : 0;
160
+ if (cs > es || (cs === es && String(cur.by) >= String(ed.by)))
161
+ return; // an existing write outranks it
162
+ }
163
+ await this.state.storage.put(k, e);
123
164
  }
124
165
  webSocketClose(ws) {
125
166
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {