ugly-game 0.5.15 → 0.5.16

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.
@@ -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.16",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {