ugly-game 0.5.15 → 0.5.17

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.
@@ -107,7 +107,7 @@ export declare class GpuShadowRenderer {
107
107
  depth: GPUTextureView;
108
108
  w: number;
109
109
  h: number;
110
- }, tint?: [number, number, number, number], extraShadow?: (sp: GPURenderPassEncoder, lightVP: Float32Array) => void, up?: [number, number, number]): void;
110
+ }, tint?: [number, number, number, number], extraShadow?: (sp: GPURenderPassEncoder, lightVP: Float32Array) => void, up?: [number, number, number], linearOut?: boolean): void;
111
111
  /** Read back the last frame's GPU cull result (camera-visible, light-visible). Does its OWN copy+submit so it's
112
112
  * safe to call from a live render loop (no per-frame readback cost, no race with the ongoing frames). IR/tests. */
113
113
  readCullStats(): Promise<{
package/dist/gpuShadow.js CHANGED
@@ -254,6 +254,7 @@ fn fixedFill(N: vec3<f32>, up: vec3<f32>) -> f32 {
254
254
  @fragment fn fs(i: VOut) -> @location(0) vec4<f32> {
255
255
  if (i.emis > 0.5) { // self-glowing effect cube: full-bright, unlit
256
256
  let g = mix(hsv(i.hue), vec3(1.0), 0.4);
257
+ if (cam.up.w > 0.5) { return vec4(g * 2.6, 1.0); } // linear-HDR path: push emissive >1 so it blooms
257
258
  return vec4(pow(clamp(g, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0);
258
259
  }
259
260
  let N = normalize(i.nrm);
@@ -287,6 +288,9 @@ fn fixedFill(N: vec3<f32>, up: vec3<f32>) -> f32 {
287
288
  let fill = cam.sun.w * (0.5 + 0.85 * fixedFill(N, cam.up.xyz));
288
289
  let key = cam.sunCol.xyz * ndl * sh; // direct sun (shadowed) — primary light
289
290
  let lit = base * (fill + key) * ao;
291
+ // cam.up.w > 0.5 selects LINEAR-HDR output (unclamped, no gamma) for an HDR pipeline that tonemaps downstream;
292
+ // otherwise the classic gamma-encoded LDR path (all existing consumers). Keeps both looks from one shader.
293
+ if (cam.up.w > 0.5) { return vec4(max(lit, vec3(0.0)), 1.0); }
290
294
  return vec4(pow(clamp(lit, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0); // gamma
291
295
  }
292
296
  `;
@@ -602,7 +606,7 @@ export class GpuShadowRenderer {
602
606
  /** camVP/lightVP: column-major Float32Array(16). sunDir: travel direction. Pass `occlusion` (the depth target +
603
607
  * its size) to enable Hi-Z occlusion culling on the camera pass — the renderer rebuilds a coarse Hi-Z from that
604
608
  * depth each frame and the NEXT frame's cull uses it (hole-free: the first frame + any resize skip occlusion). */
605
- frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false, occlusion, tint = [1, 1, 1, 0], extraShadow, up = [0, 1, 0]) {
609
+ frame(enc, colorView, sceneDepth, camVP, lightVP, sunDir, sunCol, ambient, load = false, occlusion, tint = [1, 1, 1, 0], extraShadow, up = [0, 1, 0], linearOut = false) {
606
610
  this.cam.set(camVP, 0);
607
611
  this.cam.set(lightVP, 16);
608
612
  this.cam[32] = sunDir[0];
@@ -621,6 +625,7 @@ export class GpuShadowRenderer {
621
625
  this.cam[48] = up[0];
622
626
  this.cam[49] = up[1];
623
627
  this.cam[50] = up[2];
628
+ this.cam[51] = linearOut ? 1 : 0; // up.w = linear-HDR output flag
624
629
  this.device.queue.writeBuffer(this.camBuf, 0, this.cam);
625
630
  if (occlusion)
626
631
  this.ensureHiZ(occlusion.w, occlusion.h);
@@ -0,0 +1,10 @@
1
+ export declare class GpuSSAO {
2
+ private device;
3
+ private pipe;
4
+ private uBuf;
5
+ private bg;
6
+ private u;
7
+ constructor(device: GPUDevice, format: GPUTextureFormat);
8
+ /** color = scene colour (input), depth = scene depth, out = darkened target the water pass then samples. */
9
+ frame(enc: GPUCommandEncoder, out: GPUTextureView, color: GPUTextureView, depth: GPUTextureView, camVP: Float32Array, w: number, h: number, radius?: number, bias?: number, strength?: number, maxDarken?: number): void;
10
+ }
@@ -0,0 +1,156 @@
1
+ // SCREEN-SPACE CONTACT AO — the missing cue that makes voxel terrain read as 3D. Fixed directional fill shades a
2
+ // face by its ORIENTATION, but a large flat ground plane has one normal everywhere, so it still looks flat. What
3
+ // the eye actually uses to read shape is the DARK where surfaces meet: the base of a step, the inside corner of a
4
+ // pit, the ground under a tree. This pass reconstructs each pixel's world position from the scene depth (via the
5
+ // inverse view-projection, so it's immune to the GL-vs-WebGPU depth-range convention), estimates a surface normal
6
+ // from neighbours, samples a small normal-oriented hemisphere, and darkens where nearby geometry rises above the
7
+ // tangent plane. It writes scene*ao into a second colour target the caller then composites (blit / water / post).
8
+ //
9
+ // PLATFORM PASS. Reusable by any ugly-game renderer: run it after the opaque scene is drawn, feeding the scene
10
+ // colour + depth and the same camera view-projection, then sample the AO target instead of the raw scene. Pure
11
+ // WebGPU — no engine deps.
12
+ const SHADER = /* wgsl */ `
13
+ struct U {
14
+ invVP: mat4x4<f32>,
15
+ params: vec4<f32>, // x = world radius, y = bias, z = strength, w = max darken
16
+ dim: vec4<f32>, // xy = target size in px
17
+ };
18
+ @group(0) @binding(0) var<uniform> u: U;
19
+ @group(0) @binding(1) var colTex: texture_2d<f32>;
20
+ @group(0) @binding(2) var depthTex: texture_depth_2d;
21
+
22
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
23
+ var p = array<vec2<f32>, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
24
+ return vec4(p[vi], 0.0, 1.0);
25
+ }
26
+
27
+ fn worldAt(px: vec2<i32>, dim: vec2<f32>) -> vec4<f32> {
28
+ let c = clamp(px, vec2<i32>(0, 0), vec2<i32>(dim) - vec2<i32>(1, 1));
29
+ let d = textureLoad(depthTex, c, 0);
30
+ let uv = (vec2<f32>(c) + vec2(0.5, 0.5)) / dim;
31
+ let ndc = vec4(uv.x * 2.0 - 1.0, (1.0 - uv.y) * 2.0 - 1.0, d, 1.0);
32
+ let w = u.invVP * ndc;
33
+ return vec4(w.xyz / w.w, d); // xyz = rebased world pos, w = stored depth
34
+ }
35
+
36
+ // 8 unit directions on the screen; sampled at two pixel radii for multi-scale contact darkening.
37
+ const DIRS = array<vec2<f32>, 8>(
38
+ vec2( 1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0),
39
+ vec2( 0.7, 0.7), vec2(-0.7, 0.7), vec2(0.7, -0.7), vec2(-0.7, -0.7));
40
+
41
+ @fragment fn fs(@builtin(position) fc: vec4<f32>) -> @location(0) vec4<f32> {
42
+ let px = vec2<i32>(fc.xy);
43
+ let dim = u.dim.xy;
44
+ let base = textureLoad(colTex, px, 0).rgb;
45
+ let ctr = worldAt(px, dim);
46
+ if (ctr.w >= 0.9999) { return vec4(base, 1.0); } // sky — no AO
47
+
48
+ let P = ctr.xyz;
49
+ let Px = worldAt(px + vec2<i32>(1, 0), dim).xyz;
50
+ let Py = worldAt(px + vec2<i32>(0, 1), dim).xyz;
51
+ let N = normalize(cross(Px - P, Py - P));
52
+
53
+ let radius = u.params.x;
54
+ let bias = u.params.y;
55
+ var occ = 0.0;
56
+ var wsum = 0.0;
57
+ // two pixel radii → catches both tight corners and broader valleys
58
+ let rads = array<f32, 2>(3.0, 8.0);
59
+ for (var r = 0; r < 2; r = r + 1) {
60
+ for (var i = 0; i < 8; i = i + 1) {
61
+ let o = vec2<i32>(DIRS[i] * rads[r]);
62
+ let s = worldAt(px + o, dim);
63
+ if (s.w >= 0.9999) { wsum = wsum + 1.0; continue; } // neighbour is sky → not occluding
64
+ let v = s.xyz - P;
65
+ let dist = length(v);
66
+ if (dist < 1e-4) { continue; }
67
+ // occluded when the neighbour rises above the tangent plane (dot(N,dir) > bias), attenuated by world distance
68
+ let ao = max(dot(v / dist, N) - bias, 0.0) * (1.0 - smoothstep(0.0, radius, dist));
69
+ occ = occ + ao;
70
+ wsum = wsum + 1.0;
71
+ }
72
+ }
73
+ occ = select(0.0, occ / wsum, wsum > 0.0);
74
+ let darken = clamp(occ * u.params.z, 0.0, u.params.w);
75
+ return vec4(base * (1.0 - darken), 1.0);
76
+ }`;
77
+ /** Invert a column-major 4×4 (Float32Array). Returns a new Float32Array; identity if singular. */
78
+ function invert4(m) {
79
+ const a = m;
80
+ const b = new Float32Array(16);
81
+ const m00 = a[0], m01 = a[1], m02 = a[2], m03 = a[3];
82
+ const m10 = a[4], m11 = a[5], m12 = a[6], m13 = a[7];
83
+ const m20 = a[8], m21 = a[9], m22 = a[10], m23 = a[11];
84
+ const m30 = a[12], m31 = a[13], m32 = a[14], m33 = a[15];
85
+ const b00 = m00 * m11 - m01 * m10, b01 = m00 * m12 - m02 * m10;
86
+ const b02 = m00 * m13 - m03 * m10, b03 = m01 * m12 - m02 * m11;
87
+ const b04 = m01 * m13 - m03 * m11, b05 = m02 * m13 - m03 * m12;
88
+ const b06 = m20 * m31 - m21 * m30, b07 = m20 * m32 - m22 * m30;
89
+ const b08 = m20 * m33 - m23 * m30, b09 = m21 * m32 - m22 * m31;
90
+ const b10 = m21 * m33 - m23 * m31, b11 = m22 * m33 - m23 * m32;
91
+ let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
92
+ if (!det) {
93
+ b[0] = b[5] = b[10] = b[15] = 1;
94
+ return b;
95
+ }
96
+ det = 1.0 / det;
97
+ b[0] = (m11 * b11 - m12 * b10 + m13 * b09) * det;
98
+ b[1] = (m02 * b10 - m01 * b11 - m03 * b09) * det;
99
+ b[2] = (m31 * b05 - m32 * b04 + m33 * b03) * det;
100
+ b[3] = (m22 * b04 - m21 * b05 - m23 * b03) * det;
101
+ b[4] = (m12 * b08 - m10 * b11 - m13 * b07) * det;
102
+ b[5] = (m00 * b11 - m02 * b08 + m03 * b07) * det;
103
+ b[6] = (m32 * b02 - m30 * b05 - m33 * b01) * det;
104
+ b[7] = (m20 * b05 - m22 * b02 + m23 * b01) * det;
105
+ b[8] = (m10 * b10 - m11 * b08 + m13 * b06) * det;
106
+ b[9] = (m01 * b08 - m00 * b10 - m03 * b06) * det;
107
+ b[10] = (m30 * b04 - m31 * b02 + m33 * b00) * det;
108
+ b[11] = (m21 * b02 - m20 * b04 - m23 * b00) * det;
109
+ b[12] = (m11 * b07 - m10 * b09 - m12 * b06) * det;
110
+ b[13] = (m00 * b09 - m01 * b07 + m02 * b06) * det;
111
+ b[14] = (m31 * b01 - m30 * b03 - m32 * b00) * det;
112
+ b[15] = (m20 * b03 - m21 * b01 + m22 * b00) * det;
113
+ return b;
114
+ }
115
+ export class GpuSSAO {
116
+ device;
117
+ pipe;
118
+ uBuf;
119
+ bg = null;
120
+ u = new Float32Array(24); // invVP(16) + params(4) + dim(4)
121
+ constructor(device, format) {
122
+ this.device = device;
123
+ const sm = device.createShaderModule({ code: SHADER });
124
+ this.pipe = device.createRenderPipeline({
125
+ layout: 'auto',
126
+ vertex: { module: sm, entryPoint: 'vs' },
127
+ fragment: { module: sm, entryPoint: 'fs', targets: [{ format }] },
128
+ primitive: { topology: 'triangle-list' },
129
+ });
130
+ this.uBuf = device.createBuffer({ size: 96, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
131
+ }
132
+ /** color = scene colour (input), depth = scene depth, out = darkened target the water pass then samples. */
133
+ frame(enc, out, color, depth, camVP, w, h, radius = 2.2, bias = 0.12, strength = 1.9, maxDarken = 0.72) {
134
+ this.u.set(invert4(camVP), 0);
135
+ this.u[16] = radius;
136
+ this.u[17] = bias;
137
+ this.u[18] = strength;
138
+ this.u[19] = maxDarken;
139
+ this.u[20] = w;
140
+ this.u[21] = h;
141
+ this.device.queue.writeBuffer(this.uBuf, 0, this.u);
142
+ this.bg = this.device.createBindGroup({
143
+ layout: this.pipe.getBindGroupLayout(0),
144
+ entries: [
145
+ { binding: 0, resource: { buffer: this.uBuf } },
146
+ { binding: 1, resource: color },
147
+ { binding: 2, resource: depth },
148
+ ],
149
+ });
150
+ const rp = enc.beginRenderPass({ colorAttachments: [{ view: out, loadOp: 'clear', clearValue: { r: 0, g: 0, b: 0, a: 1 }, storeOp: 'store' }] });
151
+ rp.setPipeline(this.pipe);
152
+ rp.setBindGroup(0, this.bg);
153
+ rp.draw(3);
154
+ rp.end();
155
+ }
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {