ugly-game 0.2.0 → 0.3.0

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,309 @@
1
+ // The integrated scene renderer: sun SHADOW MAPPING + TAA in one pipeline, driven by the
2
+ // deterministic ECS sim. The new hard part vs. the isolated /gputaa demo is PER-OBJECT MOTION
3
+ // VECTORS: the sim moves crates/character/projectiles every frame, so camera-only reprojection
4
+ // would smear them. Each instance therefore carries its PREVIOUS-frame world center; the scene
5
+ // pass emits a true motion vector (camera motion + object motion), and TAA reprojects history
6
+ // through it. Passes: (1) shadow depth from the sun, (2) shadow-lit scene → MRT color+motion
7
+ // (jittered), (3) TAA resolve → screen + ping-ponged history.
8
+ import { CUBE } from './gpuShadow';
9
+ const COLOR_FMT = 'rgba16float';
10
+ const MOTION_FMT = 'rg16float';
11
+ const SHADOW_SIZE = 2048;
12
+ const COMMON = /* wgsl */ `
13
+ struct Inst { pos: vec4<f32>, scl: vec4<f32>, prev: vec4<f32> }; // pos.w = hue (<0 ground)
14
+ const CUBE_POS = array<vec3<f32>, 36>(${CUBE.pos});
15
+ const CUBE_NRM = array<vec3<f32>, 36>(${CUBE.nrm});
16
+ `;
17
+ const SHADOW_WGSL = COMMON + /* wgsl */ `
18
+ @group(0) @binding(0) var<uniform> lightVP: mat4x4<f32>;
19
+ @group(0) @binding(1) var<storage, read> insts: array<Inst>;
20
+ @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> @builtin(position) vec4<f32> {
21
+ let it = insts[ii];
22
+ return lightVP * vec4(it.pos.xyz + CUBE_POS[vi] * it.scl.xyz, 1.0);
23
+ }
24
+ `;
25
+ const SCENE_WGSL = COMMON + /* wgsl */ `
26
+ struct U {
27
+ currVP: mat4x4<f32>,
28
+ prevVP: mat4x4<f32>,
29
+ lightVP: mat4x4<f32>,
30
+ jitter: vec4<f32>, // xy clip-space sub-pixel jitter
31
+ sun: vec4<f32>, // xyz dir, w ambient
32
+ params: vec4<f32>, // x shadow size
33
+ };
34
+ @group(0) @binding(0) var<uniform> u: U;
35
+ @group(0) @binding(1) var<storage, read> insts: array<Inst>;
36
+ @group(0) @binding(2) var shadowMap: texture_depth_2d;
37
+ @group(0) @binding(3) var shadowSamp: sampler_comparison;
38
+
39
+ struct VOut {
40
+ @builtin(position) clip: vec4<f32>,
41
+ @location(0) world: vec3<f32>,
42
+ @location(1) nrm: vec3<f32>,
43
+ @location(2) hue: f32,
44
+ @location(3) curr: vec4<f32>, // unjittered current clip (motion)
45
+ @location(4) prev: vec4<f32>, // previous clip (motion)
46
+ };
47
+ @vertex fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VOut {
48
+ let it = insts[ii];
49
+ let world = it.pos.xyz + CUBE_POS[vi] * it.scl.xyz;
50
+ let prevWorld = it.prev.xyz + CUBE_POS[vi] * it.scl.xyz; // scale constant frame-to-frame
51
+ let c = u.currVP * vec4(world, 1.0);
52
+ var o: VOut;
53
+ o.curr = c; o.prev = u.prevVP * vec4(prevWorld, 1.0);
54
+ var cj = c; cj.x = cj.x + u.jitter.x * c.w; cj.y = cj.y + u.jitter.y * c.w;
55
+ o.clip = cj; o.world = world; o.nrm = normalize(CUBE_NRM[vi] / it.scl.xyz); o.hue = it.pos.w;
56
+ return o;
57
+ }
58
+ fn hsv(hh: f32) -> vec3<f32> {
59
+ let h = fract(hh) * 6.0; let x = 1.0 - abs((h % 2.0) - 1.0);
60
+ if (h < 1.0) { return vec3(1.0, x, 0.0); } if (h < 2.0) { return vec3(x, 1.0, 0.0); }
61
+ if (h < 3.0) { return vec3(0.0, 1.0, x); } if (h < 4.0) { return vec3(0.0, x, 1.0); }
62
+ if (h < 5.0) { return vec3(x, 0.0, 1.0); } return vec3(1.0, 0.0, x);
63
+ }
64
+ fn shadowPCF(world: vec3<f32>, N: vec3<f32>, ndl: f32) -> f32 {
65
+ let wo = world + N * (0.06 + 0.22 * (1.0 - ndl));
66
+ let lp = u.lightVP * vec4(wo, 1.0);
67
+ let proj = lp.xyz / lp.w;
68
+ let uv = proj.xy * vec2(0.5, -0.5) + vec2(0.5, 0.5);
69
+ if (proj.z > 1.0 || proj.z < 0.0 || uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { return 1.0; }
70
+ let texel = 1.0 / u.params.x;
71
+ var s = 0.0;
72
+ for (var dy = -1; dy <= 1; dy = dy + 1) { for (var dx = -1; dx <= 1; dx = dx + 1) {
73
+ s = s + textureSampleCompareLevel(shadowMap, shadowSamp, uv + vec2(f32(dx), f32(dy)) * texel, proj.z - 0.0006);
74
+ } }
75
+ return s / 9.0;
76
+ }
77
+ struct FOut { @location(0) color: vec4<f32>, @location(1) motion: vec2<f32> };
78
+ @fragment fn fs(v: VOut) -> FOut {
79
+ let N = normalize(v.nrm);
80
+ let ndl = max(dot(N, -normalize(u.sun.xyz)), 0.0);
81
+ let base = select(mix(vec3(0.32), hsv(v.hue), 0.62), vec3(0.6, 0.62, 0.66), v.hue < 0.0);
82
+ let lit = base * (u.sun.w + ndl * shadowPCF(v.world, N, ndl));
83
+ let currUV = v.curr.xy / v.curr.w * vec2(0.5, -0.5) + vec2(0.5, 0.5);
84
+ let prevUV = v.prev.xy / v.prev.w * vec2(0.5, -0.5) + vec2(0.5, 0.5);
85
+ var o: FOut;
86
+ o.color = vec4(pow(clamp(lit, vec3(0.0), vec3(1.0)), vec3(0.4545)), 1.0);
87
+ o.motion = currUV - prevUV;
88
+ return o;
89
+ }
90
+ `;
91
+ const RESOLVE_WGSL = /* wgsl */ `
92
+ struct RU { texel: vec4<f32>, ctrl: vec4<f32> }; // texel.xy = 1/size; ctrl.x blend, ctrl.y taa on
93
+ @group(0) @binding(0) var<uniform> ru: RU;
94
+ @group(0) @binding(1) var histSamp: sampler;
95
+ @group(0) @binding(2) var colorTex: texture_2d<f32>;
96
+ @group(0) @binding(3) var motionTex: texture_2d<f32>;
97
+ @group(0) @binding(4) var histTex: texture_2d<f32>;
98
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
99
+ var p = array<vec2<f32>, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
100
+ return vec4(p[vi], 0.0, 1.0);
101
+ }
102
+ struct ROut { @location(0) screen: vec4<f32>, @location(1) hist: vec4<f32> };
103
+ @fragment fn fs(@builtin(position) fc: vec4<f32>) -> ROut {
104
+ let px = vec2<i32>(floor(fc.xy));
105
+ let cur = textureLoad(colorTex, px, 0).rgb;
106
+ var o: ROut;
107
+ if (ru.ctrl.y < 0.5) { o.screen = vec4(cur, 1.0); o.hist = vec4(cur, 1.0); return o; }
108
+ var mn = cur; var mx = cur;
109
+ for (var dy = -1; dy <= 1; dy = dy + 1) { for (var dx = -1; dx <= 1; dx = dx + 1) {
110
+ let c = textureLoad(colorTex, px + vec2<i32>(dx, dy), 0).rgb; mn = min(mn, c); mx = max(mx, c);
111
+ } }
112
+ let motion = textureLoad(motionTex, px, 0).xy;
113
+ let prevUV = fc.xy * ru.texel.xy - motion;
114
+ var outc = cur;
115
+ if (prevUV.x >= 0.0 && prevUV.x <= 1.0 && prevUV.y >= 0.0 && prevUV.y <= 1.0) {
116
+ let hist = clamp(textureSampleLevel(histTex, histSamp, prevUV, 0.0).rgb, mn, mx);
117
+ outc = mix(cur, hist, ru.ctrl.x);
118
+ }
119
+ o.screen = vec4(outc, 1.0); o.hist = vec4(outc, 1.0);
120
+ return o;
121
+ }
122
+ `;
123
+ export class GpuWorldRenderer {
124
+ device;
125
+ w = 0;
126
+ h = 0;
127
+ instBuf;
128
+ scratch = new Float32Array(0);
129
+ count = 0;
130
+ uBuf;
131
+ rBuf;
132
+ u = new Float32Array(64);
133
+ r = new Float32Array(8);
134
+ lightScratch = new Float32Array(16);
135
+ lightBuf;
136
+ shadowTex;
137
+ shadowView;
138
+ shadowSamp;
139
+ histSamp;
140
+ shadowPipe;
141
+ scenePipe;
142
+ resolvePipe;
143
+ shadowBGL;
144
+ sceneBGL;
145
+ resolveBGL;
146
+ shadowBG;
147
+ sceneBG;
148
+ colorTex;
149
+ colorView;
150
+ motionTex;
151
+ motionView;
152
+ depthTex;
153
+ depthView;
154
+ hist = [];
155
+ histView = [];
156
+ resolveBG = [];
157
+ ping = 0;
158
+ constructor(device, format) {
159
+ this.device = device;
160
+ this.uBuf = device.createBuffer({ size: this.u.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
161
+ this.rBuf = device.createBuffer({ size: this.r.byteLength, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
162
+ this.lightBuf = device.createBuffer({ size: 64, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
163
+ this.shadowTex = device.createTexture({ size: [SHADOW_SIZE, SHADOW_SIZE], format: 'depth32float', usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING });
164
+ this.shadowView = this.shadowTex.createView();
165
+ this.shadowSamp = device.createSampler({ compare: 'less' });
166
+ this.histSamp = device.createSampler({ magFilter: 'linear', minFilter: 'linear' });
167
+ const shadowMod = device.createShaderModule({ code: SHADOW_WGSL });
168
+ const sceneMod = device.createShaderModule({ code: SCENE_WGSL });
169
+ const resolveMod = device.createShaderModule({ code: RESOLVE_WGSL });
170
+ this.shadowBGL = device.createBindGroupLayout({ entries: [
171
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
172
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } }
173
+ ] });
174
+ this.shadowPipe = device.createRenderPipeline({
175
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.shadowBGL] }),
176
+ vertex: { module: shadowMod, entryPoint: 'vs' },
177
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
178
+ depthStencil: { format: 'depth32float', depthWriteEnabled: true, depthCompare: 'less' }
179
+ });
180
+ this.sceneBGL = device.createBindGroupLayout({ entries: [
181
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
182
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
183
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'depth' } },
184
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'comparison' } }
185
+ ] });
186
+ this.scenePipe = device.createRenderPipeline({
187
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.sceneBGL] }),
188
+ vertex: { module: sceneMod, entryPoint: 'vs' },
189
+ fragment: { module: sceneMod, entryPoint: 'fs', targets: [{ format: COLOR_FMT }, { format: MOTION_FMT }] },
190
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
191
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }
192
+ });
193
+ this.resolveBGL = device.createBindGroupLayout({ entries: [
194
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
195
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
196
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
197
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
198
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }
199
+ ] });
200
+ this.resolvePipe = device.createRenderPipeline({
201
+ layout: device.createPipelineLayout({ bindGroupLayouts: [this.resolveBGL] }),
202
+ vertex: { module: resolveMod, entryPoint: 'vs' },
203
+ fragment: { module: resolveMod, entryPoint: 'fs', targets: [{ format }, { format: COLOR_FMT }] },
204
+ primitive: { topology: 'triangle-list' }
205
+ });
206
+ }
207
+ resize(w, h) {
208
+ this.w = w;
209
+ this.h = h;
210
+ for (const t of [this.colorTex, this.motionTex, this.depthTex, ...this.hist])
211
+ t?.destroy();
212
+ const RA = GPUTextureUsage.RENDER_ATTACHMENT, TB = GPUTextureUsage.TEXTURE_BINDING;
213
+ const mk = (f, u) => this.device.createTexture({ size: [w, h], format: f, usage: u });
214
+ this.colorTex = mk(COLOR_FMT, RA | TB);
215
+ this.colorView = this.colorTex.createView();
216
+ this.motionTex = mk(MOTION_FMT, RA | TB);
217
+ this.motionView = this.motionTex.createView();
218
+ this.depthTex = mk('depth24plus', RA);
219
+ this.depthView = this.depthTex.createView();
220
+ this.hist = [mk(COLOR_FMT, RA | TB), mk(COLOR_FMT, RA | TB)];
221
+ this.histView = this.hist.map((t) => t.createView());
222
+ this.resolveBG = [0, 1].map((i) => this.device.createBindGroup({ layout: this.resolveBGL, entries: [
223
+ { binding: 0, resource: { buffer: this.rBuf } }, { binding: 1, resource: this.histSamp },
224
+ { binding: 2, resource: this.colorView }, { binding: 3, resource: this.motionView }, { binding: 4, resource: this.histView[i] }
225
+ ] }));
226
+ this.ping = 0;
227
+ }
228
+ setCapacity(cap) {
229
+ this.scratch = new Float32Array(cap * 12);
230
+ this.instBuf = this.device.createBuffer({ size: this.scratch.byteLength, usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST });
231
+ this.count = 0;
232
+ this.shadowBG = this.device.createBindGroup({ layout: this.shadowBGL, entries: [
233
+ { binding: 0, resource: { buffer: this.lightBuf } }, { binding: 1, resource: { buffer: this.instBuf } }
234
+ ] });
235
+ this.sceneBG = this.device.createBindGroup({ layout: this.sceneBGL, entries: [
236
+ { binding: 0, resource: { buffer: this.uBuf } }, { binding: 1, resource: { buffer: this.instBuf } },
237
+ { binding: 2, resource: this.shadowView }, { binding: 3, resource: this.shadowSamp }
238
+ ] });
239
+ }
240
+ updateInstances(list) {
241
+ const n = Math.min(list.length, this.scratch.length / 12);
242
+ for (let i = 0; i < n; i++) {
243
+ const c = list[i], o = i * 12;
244
+ this.scratch[o] = c.x;
245
+ this.scratch[o + 1] = c.y;
246
+ this.scratch[o + 2] = c.z;
247
+ this.scratch[o + 3] = c.hue;
248
+ this.scratch[o + 4] = c.sx;
249
+ this.scratch[o + 5] = c.sy;
250
+ this.scratch[o + 6] = c.sz;
251
+ this.scratch[o + 8] = c.px;
252
+ this.scratch[o + 9] = c.py;
253
+ this.scratch[o + 10] = c.pz;
254
+ }
255
+ this.device.queue.writeBuffer(this.instBuf, 0, this.scratch, 0, n * 12);
256
+ this.count = n;
257
+ }
258
+ /** currVP/prevVP/lightVP: Float32Array(16). jitter: clip sub-pixel. */
259
+ frame(enc, swapView, currVP, prevVP, lightVP, jitter, sunDir, ambient, taaOn, blend) {
260
+ this.u.set(currVP, 0);
261
+ this.u.set(prevVP, 16);
262
+ this.u.set(lightVP, 32);
263
+ this.u[48] = taaOn ? jitter[0] : 0;
264
+ this.u[49] = taaOn ? jitter[1] : 0;
265
+ this.u[52] = sunDir[0];
266
+ this.u[53] = sunDir[1];
267
+ this.u[54] = sunDir[2];
268
+ this.u[55] = ambient;
269
+ this.u[56] = SHADOW_SIZE; // params.x (params vec4 starts at float 56: x=56,y=57,z=58,w=59)
270
+ this.device.queue.writeBuffer(this.uBuf, 0, this.u);
271
+ this.lightScratch.set(lightVP);
272
+ this.device.queue.writeBuffer(this.lightBuf, 0, this.lightScratch);
273
+ this.r[0] = 1 / this.w;
274
+ this.r[1] = 1 / this.h;
275
+ this.r[4] = blend;
276
+ this.r[5] = taaOn ? 1 : 0;
277
+ this.device.queue.writeBuffer(this.rBuf, 0, this.r);
278
+ // 1) shadow depth from the sun
279
+ const sp = enc.beginRenderPass({ colorAttachments: [], depthStencilAttachment: {
280
+ view: this.shadowView, depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store'
281
+ } });
282
+ sp.setPipeline(this.shadowPipe);
283
+ sp.setBindGroup(0, this.shadowBG);
284
+ sp.draw(36, this.count);
285
+ sp.end();
286
+ // 2) shadow-lit scene → color + motion (jittered)
287
+ const mp = enc.beginRenderPass({
288
+ colorAttachments: [
289
+ { view: this.colorView, clearValue: { r: 0.02, g: 0.025, b: 0.035, a: 1 }, loadOp: 'clear', storeOp: 'store' },
290
+ { view: this.motionView, clearValue: { r: 0, g: 0, b: 0, a: 0 }, loadOp: 'clear', storeOp: 'store' }
291
+ ],
292
+ depthStencilAttachment: { view: this.depthView, depthClearValue: 1.0, depthLoadOp: 'clear', depthStoreOp: 'store' }
293
+ });
294
+ mp.setPipeline(this.scenePipe);
295
+ mp.setBindGroup(0, this.sceneBG);
296
+ mp.draw(36, this.count);
297
+ mp.end();
298
+ // 3) TAA resolve → screen + history[1-ping]
299
+ const rp = enc.beginRenderPass({ colorAttachments: [
300
+ { view: swapView, clearValue: { r: 0, g: 0, b: 0, a: 1 }, loadOp: 'clear', storeOp: 'store' },
301
+ { view: this.histView[1 - this.ping], loadOp: 'clear', storeOp: 'store' }
302
+ ] });
303
+ rp.setPipeline(this.resolvePipe);
304
+ rp.setBindGroup(0, this.resolveBG[this.ping]);
305
+ rp.draw(3);
306
+ rp.end();
307
+ this.ping = 1 - this.ping;
308
+ }
309
+ }
@@ -0,0 +1,84 @@
1
+ export type GNode = {
2
+ id: string;
3
+ op: 'uv';
4
+ } | {
5
+ id: string;
6
+ op: 'time';
7
+ } | {
8
+ id: string;
9
+ op: 'const';
10
+ v: number[];
11
+ } | {
12
+ id: string;
13
+ op: 'tex';
14
+ layer: number;
15
+ uv: string;
16
+ } | {
17
+ id: string;
18
+ op: 'checker';
19
+ uv: string;
20
+ scale: number;
21
+ } | {
22
+ id: string;
23
+ op: 'noise';
24
+ uv: string;
25
+ scale: number;
26
+ } | {
27
+ id: string;
28
+ op: 'fresnel';
29
+ power: number;
30
+ } | {
31
+ id: string;
32
+ op: 'osc';
33
+ a: string;
34
+ } | {
35
+ id: string;
36
+ op: 'mul';
37
+ a: string;
38
+ b: string;
39
+ } | {
40
+ id: string;
41
+ op: 'add';
42
+ a: string;
43
+ b: string;
44
+ } | {
45
+ id: string;
46
+ op: 'mix';
47
+ a: string;
48
+ b: string;
49
+ t: string;
50
+ } | {
51
+ id: string;
52
+ op: 'sw';
53
+ a: string;
54
+ s: string;
55
+ } | {
56
+ id: string;
57
+ op: 'out';
58
+ albedo: string;
59
+ rough: string;
60
+ metal: string;
61
+ emit: string;
62
+ };
63
+ export interface Graph {
64
+ name: string;
65
+ nodes: GNode[];
66
+ }
67
+ /** Compile a material graph to the WGSL source of `fn material(uv, N, V, t) -> Surface`. */
68
+ export declare function compileGraph(g: Graph): string;
69
+ export interface Surf {
70
+ albedo: number[];
71
+ rough: number;
72
+ metal: number;
73
+ emit: number[];
74
+ }
75
+ /** Evaluate a material graph on the CPU for one input — the reference the GPU probe is checked against. */
76
+ export declare function evalGraph(g: Graph, s: {
77
+ uv: [number, number];
78
+ n: [number, number, number];
79
+ v: [number, number, number];
80
+ t: number;
81
+ }, texConst?: number[]): Surf;
82
+ /** Does this graph use nodes whose value can't be reproduced exactly CPU↔GPU (noise → sin chaos)? */
83
+ export declare function isExactlyComparable(g: Graph): boolean;
84
+ export declare const EXAMPLE_GRAPHS: Graph[];
@@ -0,0 +1,231 @@
1
+ // An artist-facing SHADER GRAPH → WGSL compiler. Materials are authored as DATA (a DAG of typed
2
+ // nodes), not hand-written shader code — the "custom shaders without writing WGSL" answer. The
3
+ // graph authors the SURFACE (albedo/roughness/metallic/emissive); the engine's shared template
4
+ // does the PBR lighting. compileGraph() topologically emits one WGSL `let` per node and splices a
5
+ // `material()` function into the scene shader. Pure + deterministic → headlessly testable (compile
6
+ // a graph, assert the WGSL) and the same graph always yields byte-identical source.
7
+ const WID = { f32: 1, vec2: 2, vec3: 3, vec4: 4 };
8
+ const num = (x) => (Number.isInteger(x) ? `${x}.0` : `${x}`);
9
+ const vecLit = (v) => {
10
+ const w = v.length;
11
+ if (w === 1)
12
+ return { ty: 'f32', expr: num(v[0]) };
13
+ return { ty: ['f32', 'vec2', 'vec3', 'vec4'][w - 1], expr: `vec${w}<f32>(${v.map(num).join(', ')})` };
14
+ };
15
+ const asVec3 = (id, ty) => ty === 'vec3' ? id : ty === 'vec4' ? `${id}.xyz` : ty === 'vec2' ? `vec3(${id}, 0.0)` : `vec3(${id})`;
16
+ const asF32 = (id, ty) => ty === 'f32' ? id : `${id}.x`;
17
+ /** Compile a material graph to the WGSL source of `fn material(uv, N, V, t) -> Surface`. */
18
+ export function compileGraph(g) {
19
+ const byId = new Map(g.nodes.map((n) => [n.id, n]));
20
+ const out = g.nodes.find((n) => n.op === 'out');
21
+ if (!out)
22
+ throw new Error('graph has no output node');
23
+ const ty = new Map();
24
+ const lines = [];
25
+ const done = new Set();
26
+ const nm = (id) => `n_${id}`;
27
+ const visit = (id) => {
28
+ if (done.has(id))
29
+ return;
30
+ const n = byId.get(id);
31
+ if (!n)
32
+ throw new Error(`missing node ${id}`);
33
+ // resolve dependencies first (post-order)
34
+ for (const dep of depsOf(n))
35
+ visit(dep);
36
+ done.add(id);
37
+ let t = 'f32', expr = '0.0';
38
+ switch (n.op) {
39
+ case 'uv':
40
+ t = 'vec2';
41
+ expr = 'uv';
42
+ break;
43
+ case 'time':
44
+ t = 'f32';
45
+ expr = 't';
46
+ break;
47
+ case 'const': {
48
+ const c = vecLit(n.v);
49
+ t = c.ty;
50
+ expr = c.expr;
51
+ break;
52
+ }
53
+ case 'tex':
54
+ t = 'vec4';
55
+ expr = `textureSample(gtex, gsamp, ${nm(n.uv)}, ${n.layer})`;
56
+ break;
57
+ case 'checker':
58
+ t = 'f32';
59
+ expr = `gchecker(${nm(n.uv)} * ${num(n.scale)})`;
60
+ break;
61
+ case 'noise':
62
+ t = 'f32';
63
+ expr = `gnoise(${nm(n.uv)} * ${num(n.scale)})`;
64
+ break;
65
+ case 'fresnel':
66
+ t = 'f32';
67
+ expr = `pow(1.0 - max(dot(N, V), 0.0), ${num(n.power)})`;
68
+ break;
69
+ case 'osc':
70
+ t = 'f32';
71
+ expr = `(0.5 + 0.5 * sin(${asF32(nm(n.a), ty.get(n.a))}))`;
72
+ break;
73
+ case 'mul':
74
+ case 'add': {
75
+ const ta = ty.get(n.a), tb = ty.get(n.b);
76
+ t = WID[ta] >= WID[tb] ? ta : tb;
77
+ expr = `(${nm(n.a)} ${n.op === 'mul' ? '*' : '+'} ${nm(n.b)})`;
78
+ break;
79
+ }
80
+ case 'mix': {
81
+ const ta = ty.get(n.a);
82
+ t = ta;
83
+ expr = `mix(${nm(n.a)}, ${nm(n.b)}, ${asF32(nm(n.t), ty.get(n.t))})`;
84
+ break;
85
+ }
86
+ case 'sw': {
87
+ const s = n.s;
88
+ t = ['f32', 'vec2', 'vec3', 'vec4'][s.length - 1];
89
+ expr = `${nm(n.a)}.${s}`;
90
+ break;
91
+ }
92
+ case 'out': return; // handled after the walk
93
+ }
94
+ ty.set(id, t);
95
+ lines.push(` let ${nm(id)} = ${expr};`); // WGSL infers the let type
96
+ };
97
+ for (const dep of [out.albedo, out.rough, out.metal, out.emit])
98
+ visit(dep);
99
+ return `fn material(uv: vec2<f32>, N: vec3<f32>, V: vec3<f32>, t: f32) -> Surface {
100
+ ${lines.join('\n')}
101
+ var s: Surface;
102
+ s.albedo = ${asVec3(nm(out.albedo), ty.get(out.albedo))};
103
+ s.rough = clamp(${asF32(nm(out.rough), ty.get(out.rough))}, 0.04, 1.0);
104
+ s.metal = clamp(${asF32(nm(out.metal), ty.get(out.metal))}, 0.0, 1.0);
105
+ s.emit = ${asVec3(nm(out.emit), ty.get(out.emit))};
106
+ return s;
107
+ }`;
108
+ }
109
+ function depsOf(n) {
110
+ switch (n.op) {
111
+ case 'tex':
112
+ case 'checker':
113
+ case 'noise': return [n.uv];
114
+ case 'mul':
115
+ case 'add': return [n.a, n.b];
116
+ case 'mix': return [n.a, n.b, n.t];
117
+ case 'sw':
118
+ case 'osc': return [n.a];
119
+ case 'out': return [n.albedo, n.rough, n.metal, n.emit];
120
+ default: return [];
121
+ }
122
+ }
123
+ const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
124
+ const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
125
+ const bcast = (a, b, f) => { const n = Math.max(a.length, b.length), g = (v, i) => v.length === 1 ? v[0] : v[i]; return Array.from({ length: n }, (_, i) => f(g(a, i), g(b, i))); };
126
+ const toVec3 = (v) => v.length >= 3 ? [v[0], v[1], v[2]] : v.length === 2 ? [v[0], v[1], 0] : [v[0], v[0], v[0]];
127
+ function cpuNoise(x, y) {
128
+ const h = (px, py) => { const s = Math.sin(px * 12.9898 + py * 78.233) * 43758.5453; return s - Math.floor(s); };
129
+ const ix = Math.floor(x), iy = Math.floor(y), fx = x - ix, fy = y - iy, wx = fx * fx * (3 - 2 * fx), wy = fy * fy * (3 - 2 * fy);
130
+ const a = h(ix, iy), b = h(ix + 1, iy), c = h(ix, iy + 1), d = h(ix + 1, iy + 1);
131
+ return (a + (b - a) * wx) + ((c + (d - c) * wx) - (a + (b - a) * wx)) * wy;
132
+ }
133
+ /** Evaluate a material graph on the CPU for one input — the reference the GPU probe is checked against. */
134
+ export function evalGraph(g, s, texConst = [0.7843, 0.7843, 0.7843, 1]) {
135
+ const byId = new Map(g.nodes.map((n) => [n.id, n])), memo = new Map();
136
+ const val = (id) => {
137
+ if (memo.has(id))
138
+ return memo.get(id);
139
+ const n = byId.get(id);
140
+ let r = [0];
141
+ switch (n.op) {
142
+ case 'uv':
143
+ r = [s.uv[0], s.uv[1]];
144
+ break;
145
+ case 'time':
146
+ r = [s.t];
147
+ break;
148
+ case 'const':
149
+ r = [...n.v];
150
+ break;
151
+ case 'tex':
152
+ val(n.uv);
153
+ r = [...texConst];
154
+ break;
155
+ case 'checker': {
156
+ const u = val(n.uv);
157
+ r = [((Math.floor(u[0] * n.scale) + Math.floor(u[1] * n.scale)) & 1) ? 1 : 0];
158
+ break;
159
+ }
160
+ case 'noise': {
161
+ const u = val(n.uv);
162
+ r = [cpuNoise(u[0] * n.scale, u[1] * n.scale)];
163
+ break;
164
+ }
165
+ case 'fresnel':
166
+ r = [Math.pow(1 - Math.max(dot3(s.n, s.v), 0), n.power)];
167
+ break;
168
+ case 'osc':
169
+ r = [0.5 + 0.5 * Math.sin(val(n.a)[0])];
170
+ break;
171
+ case 'mul':
172
+ r = bcast(val(n.a), val(n.b), (x, y) => x * y);
173
+ break;
174
+ case 'add':
175
+ r = bcast(val(n.a), val(n.b), (x, y) => x + y);
176
+ break;
177
+ case 'mix': {
178
+ const a = val(n.a), b = val(n.b), tt = val(n.t)[0];
179
+ r = a.map((av, i) => av + (b[i] - av) * tt);
180
+ break;
181
+ }
182
+ case 'sw': {
183
+ const a = val(n.a), idx = { x: 0, y: 1, z: 2, w: 3 };
184
+ r = Array.from(n.s).map((ch) => a[idx[ch]]);
185
+ break;
186
+ }
187
+ case 'out':
188
+ r = [0];
189
+ break;
190
+ }
191
+ memo.set(id, r);
192
+ return r;
193
+ };
194
+ const o = g.nodes.find((n) => n.op === 'out');
195
+ if (!o)
196
+ throw new Error('no out');
197
+ return { albedo: toVec3(val(o.albedo)), rough: clamp(val(o.rough)[0], 0.04, 1), metal: clamp(val(o.metal)[0], 0, 1), emit: toVec3(val(o.emit)) };
198
+ }
199
+ /** Does this graph use nodes whose value can't be reproduced exactly CPU↔GPU (noise → sin chaos)? */
200
+ export function isExactlyComparable(g) { return !g.nodes.some((n) => n.op === 'noise'); }
201
+ // ── example material graphs (DATA, not shader code) ──────────────────────────────────────────
202
+ export const EXAMPLE_GRAPHS = [
203
+ { name: 'brushed metal', nodes: [
204
+ { id: 'uv', op: 'uv' }, { id: 'str', op: 'noise', uv: 'uv', scale: 40 },
205
+ { id: 'tint', op: 'const', v: [0.62, 0.64, 0.70] }, { id: 'strv', op: 'mul', a: 'tint', b: 'str' },
206
+ { id: 'alb', op: 'mix', a: 'tint', b: 'strv', t: 'str' },
207
+ { id: 'r', op: 'const', v: [0.28] }, { id: 'm', op: 'const', v: [1.0] }, { id: 'e', op: 'const', v: [0, 0, 0] },
208
+ { id: 'out', op: 'out', albedo: 'alb', rough: 'r', metal: 'm', emit: 'e' }
209
+ ] },
210
+ { name: 'checker tile', nodes: [
211
+ { id: 'uv', op: 'uv' }, { id: 'ch', op: 'checker', uv: 'uv', scale: 5 },
212
+ { id: 'a', op: 'const', v: [0.9, 0.2, 0.2] }, { id: 'b', op: 'const', v: [0.95, 0.95, 0.95] },
213
+ { id: 'alb', op: 'mix', a: 'a', b: 'b', t: 'ch' },
214
+ { id: 'r', op: 'const', v: [0.5] }, { id: 'm', op: 'const', v: [0.0] }, { id: 'e', op: 'const', v: [0, 0, 0] },
215
+ { id: 'out', op: 'out', albedo: 'alb', rough: 'r', metal: 'm', emit: 'e' }
216
+ ] },
217
+ { name: 'fresnel glow', nodes: [
218
+ { id: 'base', op: 'const', v: [0.05, 0.09, 0.16] }, { id: 'fr', op: 'fresnel', power: 3 },
219
+ { id: 'rim', op: 'const', v: [0.2, 0.9, 1.0] }, { id: 'glow', op: 'mul', a: 'rim', b: 'fr' },
220
+ { id: 'r', op: 'const', v: [0.15] }, { id: 'm', op: 'const', v: [0.0] },
221
+ { id: 'out', op: 'out', albedo: 'base', rough: 'r', metal: 'm', emit: 'glow' }
222
+ ] },
223
+ { name: 'animated pulse', nodes: [
224
+ { id: 'uv', op: 'uv' }, { id: 't', op: 'time' }, { id: 'n', op: 'noise', uv: 'uv', scale: 8 },
225
+ { id: 'freq', op: 'const', v: [6.28] }, { id: 'nph', op: 'mul', a: 'n', b: 'freq' }, { id: 'phase', op: 'add', a: 'nph', b: 't' },
226
+ { id: 'p', op: 'osc', a: 'phase' }, // BOUNDED 0..1 pulse (was noise+time → unbounded blow-out)
227
+ { id: 'col', op: 'const', v: [1.0, 0.4, 0.1] }, { id: 'em', op: 'mul', a: 'col', b: 'p' },
228
+ { id: 'dark', op: 'const', v: [0.1, 0.05, 0.03] }, { id: 'r', op: 'const', v: [0.6] }, { id: 'm', op: 'const', v: [0.0] },
229
+ { id: 'out', op: 'out', albedo: 'dark', rough: 'r', metal: 'm', emit: 'em' }
230
+ ] },
231
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-game",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },