ugly-game 0.7.8 → 0.7.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.
@@ -1,3 +1,25 @@
1
+ /**
2
+ * Per-world surface + motion parameters.
3
+ *
4
+ * `windAmp` should come from the world's AIR DENSITY: an airless world's flora is perfectly, eerily
5
+ * still, which is both physically correct and an instant read on where you are. `hueShift` and
6
+ * `saturation` carry the star-spectrum grade, so one planet's forest is waxy blue-green and the
7
+ * next is dry and rust-coloured from identical meshes.
8
+ */
9
+ export interface PropWorld {
10
+ windDir: [number, number];
11
+ /** 0 disables sway entirely (and the vertex path early-outs). */
12
+ windAmp: number;
13
+ /** 0 disables the procedural grain. Larger = finer detail. */
14
+ grainScale: number;
15
+ grainDepth: number;
16
+ roughness: number;
17
+ /** bioluminescence — flat emissive added on dark worlds */
18
+ emissive: number;
19
+ hueShift: number;
20
+ saturation: number;
21
+ valueLift: number;
22
+ }
1
23
  export interface PropMesh {
2
24
  positions: Float32Array;
3
25
  normals: Float32Array;
@@ -24,6 +46,17 @@ export declare class GpuPropsRenderer {
24
46
  private shadowBG;
25
47
  private lastSV;
26
48
  private lastSS;
49
+ /** Per-world look + motion. Defaults are inert: no sway, no grain, no grade. */
50
+ private world;
51
+ private phase;
52
+ /**
53
+ * Set the per-world look and motion. This is the whole point of the procedural surface: the same
54
+ * geometry and the same shader render a different planet's flora by moving a handful of numbers —
55
+ * no per-model textures to author, download, or keep coherent.
56
+ */
57
+ setWorld(w: Partial<PropWorld>): void;
58
+ /** Advance the wind phase. Pass the frame delta; sway is skipped entirely when windAmp is 0. */
59
+ tick(dt: number): void;
27
60
  constructor(device: GPUDevice, format: GPUTextureFormat);
28
61
  setModels(meshes: PropMesh[]): void;
29
62
  /** Group instances by model and upload per-model transform buffers. */
@@ -4,16 +4,37 @@
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>, lightVP: mat4x4<f32>, sun: vec4<f32>, sunCol: vec4<f32>, amb: vec4<f32>, cam: vec4<f32>, fog: vec4<f32>, up: vec4<f32> };
7
+ // wind = (dirX, dirZ, amplitude, phase) — sway, amplitude scaled by the world's air density
8
+ // surf = (grainScale, grainDepth, roughness, emissive)
9
+ // grade = (hueShift, saturation, valueLift, unused)
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> };
8
11
  @group(0) @binding(0) var<uniform> u: U;
9
12
  @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
10
13
  @group(1) @binding(0) var shadowMap: texture_depth_2d; // the SAME sun shadow map the terrain cubes sample
11
14
  @group(1) @binding(1) var shadowSamp: sampler_comparison;
12
- struct VO { @builtin(position) pos: vec4<f32>, @location(0) world: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec3<f32> };
15
+ 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> };
16
+ // WIND SWAY. Displacement is weighted by height above the instance's own anchor, so a trunk stays
17
+ // planted while the canopy moves, and the phase is offset by world position so a grove never sways
18
+ // in unison. Amplitude comes from the world's air density: an airless world is perfectly, eerily
19
+ // still — physically right, and a strong signal of where you are.
20
+ fn sway(local: vec3<f32>, world: vec3<f32>, up: vec3<f32>, scale: f32) -> vec3<f32> {
21
+ if (u.wind.z <= 0.0001) { return world; }
22
+ let h = max(dot(local, vec3(0.0, 1.0, 0.0)), 0.0); // model +Y is local up (see placeMat)
23
+ let stiff = pow(min(h * 0.22, 1.0), 1.6); // stiff at the base, loose at the tip
24
+ let ph = u.wind.w + dot(world.xz, vec2(0.13, 0.17));
25
+ let a = u.wind.z * stiff * scale;
26
+ let d = a * (sin(ph) + 0.35 * sin(ph * 2.7 + h));
27
+ var wd = vec3(u.wind.x, 0.0, u.wind.y);
28
+ wd = normalize(wd - up * dot(wd, up)); // project into the local tangent plane
29
+ return world + wd * d;
30
+ }
13
31
  @vertex fn vs(@location(0) p: vec3<f32>, @location(1) nrm: vec3<f32>, @location(2) col: vec3<f32>, @builtin(instance_index) ii: u32) -> VO {
14
32
  let m = models[ii];
15
- let world = (m * vec4(p, 1.0)).xyz;
16
- 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; return o;
33
+ var world = (m * vec4(p, 1.0)).xyz;
34
+ let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
35
+ 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;
17
38
  }
18
39
  // same fixed directional FILL as the terrain cubes (gpuShadow.fixedFill) so plants/rocks read the same 3D shape,
19
40
  // day or night, under one shared light. up.w > 0.5 signals a valid local-up was supplied (else fall back to flat).
@@ -40,22 +61,84 @@ fn shadowFactor(world: vec3<f32>, ndl: f32) -> f32 {
40
61
  }
41
62
  return s / 9.0;
42
63
  }
64
+ // ── PROCEDURAL SURFACE ───────────────────────────────────────────────────────
65
+ // Detail is generated in the shader rather than sampled from a texture. Three reasons, all of them
66
+ // load-bearing for a procedurally-populated universe: generated meshes have unreliable UVs (so
67
+ // tri-planar, which needs none); there are no maps to download (the asset budget is already tight);
68
+ // and the SAME code yields a different surface per planet by shifting a handful of parameters, which
69
+ // is exactly what an infinite universe needs. Cohesion survives because every organism resolves
70
+ // through one shared parameter set, not a per-model texture.
71
+ fn hash3(p: vec3<f32>) -> f32 {
72
+ return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
73
+ }
74
+ fn vnoise(p: vec3<f32>) -> f32 {
75
+ let i = floor(p); let f = fract(p);
76
+ let w = f * f * (3.0 - 2.0 * f);
77
+ let n000 = hash3(i); let n100 = hash3(i + vec3(1.0, 0.0, 0.0));
78
+ let n010 = hash3(i + vec3(0.0, 1.0, 0.0)); let n110 = hash3(i + vec3(1.0, 1.0, 0.0));
79
+ let n001 = hash3(i + vec3(0.0, 0.0, 1.0)); let n101 = hash3(i + vec3(1.0, 0.0, 1.0));
80
+ let n011 = hash3(i + vec3(0.0, 1.0, 1.0)); let n111 = hash3(i + vec3(1.0, 1.0, 1.0));
81
+ return mix(mix(mix(n000, n100, w.x), mix(n010, n110, w.x), w.y),
82
+ mix(mix(n001, n101, w.x), mix(n011, n111, w.x), w.y), w.z);
83
+ }
84
+ /** Tri-planar fbm — no UVs required, which matters because generated meshes rarely have usable ones. */
85
+ fn triplanar(p: vec3<f32>, n: vec3<f32>, scale: f32) -> f32 {
86
+ let q = p * scale;
87
+ let w = abs(normalize(n));
88
+ let wn = w / max(w.x + w.y + w.z, 0.0001);
89
+ var v = vnoise(vec3(q.y, q.z, 0.0)) * wn.x
90
+ + vnoise(vec3(q.x, q.z, 1.7)) * wn.y
91
+ + vnoise(vec3(q.x, q.y, 3.3)) * wn.z;
92
+ v = v * 0.65 + 0.35 * vnoise(q * 2.7 + 11.0); // a second octave for fine break-up
93
+ return v;
94
+ }
95
+ fn hsvShift(c: vec3<f32>, hue: f32, sat: f32, val: f32) -> vec3<f32> {
96
+ let k = vec3(0.57735, 0.57735, 0.57735); // rotate about the grey axis
97
+ let cosA = cos(hue); let sinA = sin(hue);
98
+ let rot = c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
99
+ let g = dot(rot, vec3(0.299, 0.587, 0.114));
100
+ return clamp(mix(vec3(g, g, g), rot, sat) * val, vec3(0.0), vec3(4.0));
101
+ }
43
102
  @fragment fn fs(i: VO) -> @location(0) vec4<f32> {
44
103
  let N = normalize(i.n);
45
104
  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)
46
105
  let d = clamp(dot(N, -normalize(u.sun.xyz)), 0.0, 1.0);
47
106
  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);
107
+ // procedural grain modulates the role colour; the per-world grade then re-tints the whole planet
108
+ var base = i.c;
109
+ if (u.surf.x > 0.0) {
110
+ let grain = triplanar(i.l, N, u.surf.x);
111
+ base = base * (1.0 - u.surf.y * 0.5 + u.surf.y * grain);
112
+ }
113
+ base = hsvShift(base, u.grade.x, max(u.grade.y, 0.0), max(u.grade.z, 0.0));
114
+ let spec = u.surf.z;
115
+ let lit = base * (u.amb.xyz * ff + d * u.sunCol.xyz * sh * (1.0 + spec * 0.6))
116
+ + base * u.surf.w; // emissive — bioluminescence on dark worlds
49
117
  let dist = length(i.world - u.cam.xyz);
50
118
  let f = clamp(1.0 - exp(-dist * u.fog.w), 0.0, 1.0);
51
119
  return vec4(mix(lit, u.fog.xyz, f), 1.0);
52
120
  }`;
53
121
  // Depth-only occluder pass: instanced prop depth from the sun's ortho view (so trees/rocks cast shadows).
122
+ // Depth-only occluder. It MUST apply the identical sway, or the shadows stay put while the leaves
123
+ // move — a very visible detachment.
54
124
  const OCCLUDER = /* wgsl */ `
55
- @group(0) @binding(0) var<uniform> lightVP: mat4x4<f32>;
125
+ struct OU { lightVP: mat4x4<f32>, wind: vec4<f32> };
126
+ @group(0) @binding(0) var<uniform> o: OU;
56
127
  @group(0) @binding(1) var<storage,read> models: array<mat4x4<f32>>;
57
128
  @vertex fn vs(@location(0) p: vec3<f32>, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
58
- return lightVP * (models[ii] * vec4(p, 1.0));
129
+ let m = models[ii];
130
+ var world = (m * vec4(p, 1.0)).xyz;
131
+ if (o.wind.z > 0.0001) {
132
+ let upv = normalize((m * vec4(0.0, 1.0, 0.0, 0.0)).xyz);
133
+ let h = max(p.y, 0.0);
134
+ let stiff = pow(min(h * 0.22, 1.0), 1.6);
135
+ let ph = o.wind.w + dot(world.xz, vec2(0.13, 0.17));
136
+ let a = o.wind.z * stiff * length(m[0].xyz);
137
+ var wd = vec3(o.wind.x, 0.0, o.wind.y);
138
+ wd = normalize(wd - upv * dot(wd, upv));
139
+ world = world + wd * (a * (sin(ph) + 0.35 * sin(ph * 2.7 + h)));
140
+ }
141
+ return o.lightVP * vec4(world, 1.0);
59
142
  }`;
60
143
  export class GpuPropsRenderer {
61
144
  device;
@@ -75,6 +158,32 @@ export class GpuPropsRenderer {
75
158
  shadowBG = null;
76
159
  lastSV = null;
77
160
  lastSS = null;
161
+ /** Per-world look + motion. Defaults are inert: no sway, no grain, no grade. */
162
+ world = {
163
+ windDir: [1, 0],
164
+ windAmp: 0,
165
+ grainScale: 0,
166
+ grainDepth: 0,
167
+ roughness: 0,
168
+ emissive: 0,
169
+ hueShift: 0,
170
+ saturation: 1,
171
+ valueLift: 1,
172
+ };
173
+ phase = 0;
174
+ /**
175
+ * Set the per-world look and motion. This is the whole point of the procedural surface: the same
176
+ * geometry and the same shader render a different planet's flora by moving a handful of numbers —
177
+ * no per-model textures to author, download, or keep coherent.
178
+ */
179
+ setWorld(w) {
180
+ this.world = { ...this.world, ...w };
181
+ }
182
+ /** Advance the wind phase. Pass the frame delta; sway is skipped entirely when windAmp is 0. */
183
+ tick(dt) {
184
+ if (this.world.windAmp > 0)
185
+ this.phase += dt * (0.8 + this.world.windAmp * 2.4);
186
+ }
78
187
  constructor(device, format) {
79
188
  this.device = device;
80
189
  const sm = device.createShaderModule({ code: SHADER });
@@ -127,11 +236,11 @@ export class GpuPropsRenderer {
127
236
  },
128
237
  });
129
238
  this.uBuf = device.createBuffer({
130
- size: 128 + 16 * 6, // 2× mat4 (mvp + lightVP) + 6× vec4
239
+ size: 128 + 16 * 9, // 2× mat4 (mvp + lightVP) + 9× vec4 (…, wind, surf, grade)
131
240
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
132
241
  });
133
242
  this.lightBuf = device.createBuffer({
134
- size: 64,
243
+ size: 64 + 16, // lightVP + wind (the occluder sways identically or shadows detach)
135
244
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
136
245
  });
137
246
  const dummy = device.createTexture({
@@ -213,7 +322,15 @@ export class GpuPropsRenderer {
213
322
  }
214
323
  /** Occluder pass: draw all instanced props' depth into the shared shadow map from the sun's ortho view. */
215
324
  shadowPass(pass, lightVP) {
216
- this.device.queue.writeBuffer(this.lightBuf, 0, lightVP);
325
+ const ob = new Float32Array(20);
326
+ ob.set(lightVP, 0);
327
+ ob.set([
328
+ this.world.windDir[0],
329
+ this.world.windDir[1],
330
+ this.world.windAmp,
331
+ this.phase,
332
+ ], 16);
333
+ this.device.queue.writeBuffer(this.lightBuf, 0, ob);
217
334
  pass.setPipeline(this.occPipe);
218
335
  for (let m = 0; m < this.models.length; m++) {
219
336
  const bg = this.occBGs[m], mdl = this.models[m];
@@ -230,7 +347,7 @@ export class GpuPropsRenderer {
230
347
  // in the same shadow. Omit all three (the demo path) → no shadowing, byte-identical to before.
231
348
  lightVP, shadowView, shadowSamp) {
232
349
  const receive = !!(lightVP && shadowView && shadowSamp);
233
- const u = new Float32Array(56);
350
+ const u = new Float32Array(68);
234
351
  u.set(camVP, 0);
235
352
  u.set(lightVP ?? IDENTITY4, 16); // lightVP (identity when not receiving — result is gated off by sun.w)
236
353
  u.set([sunDir[0], sunDir[1], sunDir[2], receive ? 1 : 0], 32); // sun.w = shadow-receive enable
@@ -240,6 +357,12 @@ export class GpuPropsRenderer {
240
357
  u.set([fog[0], fog[1], fog[2], fogDensity], 48);
241
358
  if (up)
242
359
  u.set([up[0], up[1], up[2], 1], 52); // up.w=1 → enable the fixed directional fill (matches the terrain)
360
+ // wind / surface / grade — all default to OFF, so a caller that never calls setWorld() renders
361
+ // byte-identically to before this feature existed.
362
+ const w = this.world;
363
+ u.set([w.windDir[0], w.windDir[1], w.windAmp, this.phase], 56);
364
+ u.set([w.grainScale, w.grainDepth, w.roughness, w.emissive], 60);
365
+ u.set([w.hueShift, w.saturation, w.valueLift, 0], 64);
243
366
  this.device.queue.writeBuffer(this.uBuf, 0, u);
244
367
  // group 1: the shadow map + comparison sampler (dummy when not receiving). Rebuild only when the view changes.
245
368
  const sv = shadowView ?? this.dummyShadowView, ss = shadowSamp ?? this.dummyShadowSamp;
package/dist/gpuShadow.js CHANGED
@@ -91,10 +91,12 @@ fn hsv(hh: f32) -> vec3<f32> {
91
91
  // OCCLUSION CULLING (Hi-Z). After the frustum test, an in-frustum cube is ALSO dropped if it's fully hidden behind
92
92
  // nearer geometry. `hiz` is a coarse MAX-depth buffer (each texel = the farthest visible NDC depth over its block)
93
93
  // built from the PREVIOUS frame's depth. A cube is occluded iff its nearest corner is behind the farthest occluder
94
- // across its whole screen footprint. Using last frame's depth + a coarse buffer makes this HOLE-FREE by
95
- // construction: a newly-disoccluded area has far/cleared depth so nothing there is culledthe worst case is
96
- // under-culling for one frame, never a false cull. occ.x toggles it (camera pass only; the shadow pass never
97
- // occludes an off-screen occluder still casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
94
+ // across its whole screen footprint. Because the depth is one frame STALE, a cube reprojects a few pixels under
95
+ // camera motion and can be tested against near depth from where an occluder used to be a false cull that blinks
96
+ // the cube out for a frame. To absorb that, the footprint query is DILATED by 2 Hi-Z texels (see below); MAX over
97
+ // the larger region only ever raises the occluder distance, so it strictly keeps more cubes and never hides real
98
+ // geometry. occ.x toggles it (camera pass only; the shadow pass never occludes — an off-screen occluder still
99
+ // casts a shadow into view). occ.yz = hiz dims, occ.w = pixels/texel.
98
100
  const CULL_WGSL = /* wgsl */ `
99
101
  struct Inst { pos: vec4<f32>, scl: vec4<f32>, rot: vec4<f32>, aux: vec4<f32> };
100
102
  struct Args { vc: u32, ic: atomic<u32>, fv: u32, fi: u32 };
@@ -12,6 +12,17 @@
12
12
  // • touch: one finger = cursor (tap = place edge); two fingers = pan + pinch-zoom + twist.
13
13
  // ─────────────────────────────────────────────────────────────────────────────
14
14
  import { emptyFrame } from './resolve';
15
+ // setPointerCapture throws InvalidStateError when a pointer-lock request is still in-flight or the pointerId is no
16
+ // longer active. A failed capture only means an off-canvas drag won't keep tracking — never fatal — so swallow it
17
+ // rather than let it bubble as an "Uncaught InvalidStateError".
18
+ function safeCapture(el, pointerId) {
19
+ try {
20
+ el.setPointerCapture(pointerId);
21
+ }
22
+ catch {
23
+ /* pointer inactive or lock pending — capture simply doesn't apply this gesture */
24
+ }
25
+ }
15
26
  export class BrowserRawBackend {
16
27
  keysDown = new Set();
17
28
  edges = new Set();
@@ -121,7 +132,7 @@ export class BrowserRawBackend {
121
132
  this.touches.set(e.pointerId, { x: e.clientX, y: e.clientY, moved: 0 });
122
133
  this.gPrev = this.touches.size === 2 ? this.gesture() : null;
123
134
  if (!document.pointerLockElement)
124
- el.setPointerCapture(e.pointerId); // track gestures that leave the canvas
135
+ safeCapture(el, e.pointerId); // track gestures that leave the canvas
125
136
  }
126
137
  else {
127
138
  if (!this.pointerButtons.has(e.button))
@@ -130,8 +141,11 @@ export class BrowserRawBackend {
130
141
  // Pointer LOCK and pointer CAPTURE are mutually-exclusive ways to route pointer input. Calling capture
131
142
  // while lock is engaged/pending throws InvalidStateError (Firefox then never locks → no mouselook;
132
143
  // Chrome logs it on every click while locked). So when the game wants mouselook use ONLY pointer lock;
133
- // otherwise capture so a drag keeps tracking off-canvas. `document.pointerLockElement` is the guard that
134
- // makes capture valid by construction no try/catch needed.
144
+ // otherwise capture so a drag keeps tracking off-canvas. `document.pointerLockElement` narrows the window,
145
+ // but it's NOT sufficient: a click landing while a requestPointerLock() is still PENDING (lockElement is
146
+ // null but the browser has a lock in-flight), or on a pointerId the UA no longer considers active, still
147
+ // throws InvalidStateError — which surfaced as an "Uncaught InvalidStateError" in prod. `safeCapture`
148
+ // swallows it (a failed capture just means a drag won't track past the canvas edge — harmless).
135
149
  if (this.pointerLock) {
136
150
  if (!document.pointerLockElement)
137
151
  void Promise.resolve(el.requestPointerLock()).catch(() => {
@@ -139,7 +153,7 @@ export class BrowserRawBackend {
139
153
  });
140
154
  }
141
155
  else if (!document.pointerLockElement) {
142
- el.setPointerCapture(e.pointerId);
156
+ safeCapture(el, e.pointerId);
143
157
  }
144
158
  }
145
159
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.7.8",
3
+ "version": "0.7.9",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {