ponko 0.0.1

A fast 2D game engine for simulating millions of things: ECS on all cores, hot reload, an in-game inspector, AI-agent control, and a C++ API.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
// Instanced 3D meshes: one instance = one Model, lit by a sun (Cook-Torrance
// GGX, metallic-roughness, as glTF) and sky/ground ambient light.
//
// The engine builds each material shader from three parts: this prelude,
// the game's shader (its `vertex` and `surface` functions, when it has
// them) and `mesh_main.wgsl`, which calls them.

const PI: f32 = 3.14159265;

struct ReflectionVolume { center: vec4<f32>, size: vec4<f32>, params: vec4<f32> };

struct Frame {
    view_proj: mat4x4<f32>,
    // World to each shadow cascade's clip space (see `sunlight`).
    sun_view_proj: array<mat4x4<f32>, 4>,
    // In the shadow pass: the cascade being drawn.
    caster_view_proj: mat4x4<f32>,
    camera: vec4<f32>,     // xyz: camera position; w: 0 in shadow maps, else 1
    sun_dir: vec4<f32>,    // xyz: unit vector towards the sun
    sun_color: vec4<f32>,  // rgb: linear color times intensity
    sky: vec4<f32>,        // rgb: linear, times ambient intensity
    ground: vec4<f32>,
    shadow: vec4<f32>,     // cascades (0: no shadows), texel size in uv, normal offset in texels, fade distance
    cascade_texel: vec4<f32>, // each cascade's texel size, in world units
    time: vec4<f32>,       // x: seconds since the game started, y: seconds since the last frame
    inv_view_proj: mat4x4<f32>, // clip space back to the world
    screen: vec4<f32>,     // width and height in pixels, then their inverses
    atmosphere: vec4<f32>, // x: a physical sky (1) or not (0), y: haze, z: cos of the sun's radius, w: stars
    sky_light: vec4<f32>,  // rgb: the sunlight the sky is lit by, w: camera height in km
    fog: vec4<f32>,        // x: density per unit, y: fog height, z: 1 / falloff, w: light shafts
    fog_color: vec4<f32>,  // rgb: linear, without an atmosphere
    night: vec4<f32>,      // rgb: light at night
    fog_far: vec4<f32>,    // x: distance fog start, y: its end (0: none), z: sun glow, w: sun focus
    cluster_depth: vec4<f32>,
    cluster_view_proj: mat4x4<f32>,
    // World to each point and spot light shadow view's clip space.
    light_view_proj: array<mat4x4<f32>, 24>,
    // Each view's tile in the light shadow atlas, in uv: x, y, side.
    light_rect: array<vec4<f32>, 24>,
    light_shadow: vec4<f32>, // views (0: none), atlas texel size in uv, normal offset in texels, w: sun shadow filter (1 to 3)
    probes: array<ReflectionVolume, 4>,
    // Temporal antialiasing: `view_proj` carries the sub-pixel jitter; these
    // are the camera without it, and last frame's, to tell motion from jitter.
    unjittered_view_proj: mat4x4<f32>,
    previous_view_proj: mat4x4<f32>,
    jitter: vec4<f32>,     // xy: this frame's jitter in pixels (0 without TAA)
    gi: array<vec4<f32>, 3>, // global illumination's probe grid (see gi.wgsl)
};

struct MaterialData {
    color: vec4<f32>,      // linear RGBA
    emissive: vec4<f32>,   // linear RGB
    params: vec4<f32>,     // roughness, metallic, normal map strength
    maps: vec4<u32>,       // has: normal map, ORM map, emissive map
    custom: array<vec4<f32>, 4>,
};

@group(0) @binding(0) var<uniform> frame: Frame;
@group(0) @binding(1) var shadow_map: texture_depth_2d_array;
@group(0) @binding(2) var shadow_sampler: sampler_comparison;
// The sky around the camera (see sky_radiance), made each frame.
@group(0) @binding(3) var sky_texture: texture_2d<f32>;
@group(0) @binding(4) var sky_sampler: sampler;
@group(0) @binding(5) var punctual_records: texture_2d<f32>;
@group(0) @binding(6) var punctual_clusters: texture_2d<u32>;
// Point and spot lights' shadow views, one tile each (see punctual_shadow).
@group(0) @binding(7) var light_shadow_map: texture_depth_2d;
@group(1) @binding(0) var<uniform> material: MaterialData;
@group(1) @binding(1) var material_sampler: sampler;
@group(1) @binding(2) var base_texture: texture_2d<f32>;
@group(1) @binding(3) var normal_texture: texture_2d<f32>;
@group(1) @binding(4) var orm_texture: texture_2d<f32>;
@group(1) @binding(5) var emissive_texture: texture_2d<f32>;
@group(1) @binding(6) var custom_texture0: texture_2d<f32>;
@group(1) @binding(7) var custom_texture1: texture_2d<f32>;
@group(1) @binding(8) var custom_array0: texture_2d_array<f32>;
@group(1) @binding(9) var custom_array1: texture_2d_array<f32>;
@group(1) @binding(10) var custom_array2: texture_2d_array<f32>;
// The opaque scene behind a transparent surface (see scene_color).
@group(2) @binding(0) var scene_color_texture: texture_2d<f32>;
@group(2) @binding(1) var scene_depth_texture: texture_depth_multisampled_2d;

// A vertex in world space, as a shader's `vertex` function gets and returns it.
struct Vertex {
    instance_index: u32, // index into a mesh GPU buffer
    position: vec3<f32>,
    normal: vec3<f32>,
    uv: vec2<f32>,
    color: vec4<f32>, // the model's tint times the vertex color
    uv1: vec2<f32>,   // the mesh's second texture coordinates (zero without)
    origin: vec3<f32>, // read-only: where the model is (its transform's position)
};

// A pixel's surface, as a shader's `surface` function gets and returns it:
// filled in from the material and its textures first.
struct Surface {
    albedo: vec3<f32>,
    alpha: f32,
    normal: vec3<f32>,   // world space, unit length
    roughness: f32,
    metallic: f32,
    occlusion: f32,      // 1: fully lit by ambient light, 0: none
    emissive: vec3<f32>,
    // Read-only inputs.
    world: vec3<f32>,
    uv: vec2<f32>,
    color: vec4<f32>,    // the model's tint
    view: vec3<f32>,     // unit vector from the surface to the camera
    screen: vec2<f32>,   // the pixel, in pixels from the top left
    uv1: vec2<f32>,      // the mesh's second texture coordinates (zero without)
    origin: vec3<f32>,   // where the model is (its transform's position)
};

// Whether this draw is into a shadow map (seen from the sun), not for a camera.
fn drawing_shadows() -> bool {
    return frame.camera.w == 0.0;
}

// The unit direction from `p` towards whoever is looking: the camera, or
// the sun when drawing shadow maps. Billboards face it in their `vertex`
// function, so their shadows are cast by the side the sun sees.
fn toward_viewer(p: vec3<f32>) -> vec3<f32> {
    if drawing_shadows() {
        return frame.sun_dir.xyz;
    }
    return normalize(frame.camera.xyz - p);
}

// The opaque scene's light at a pixel, before tone mapping (in screen
// effects: the frame so far). Only transparent materials and screen effects
// see the scene; elsewhere this is black.
fn scene_color(screen: vec2<f32>) -> vec3<f32> {
    let size = vec2<i32>(textureDimensions(scene_color_texture));
    let at = clamp(vec2<i32>(screen), vec2<i32>(0), size - 1);
    return textureLoad(scene_color_texture, at, 0).rgb;
}

// Where the opaque scene is at a pixel, in the world. Only transparent
// materials and screen effects see the scene; elsewhere this is as far as
// the camera sees.
fn scene_position(screen: vec2<f32>) -> vec3<f32> {
    let size = vec2<i32>(textureDimensions(scene_depth_texture));
    let at = clamp(vec2<i32>(screen), vec2<i32>(0), size - 1);
    let depth = textureLoad(scene_depth_texture, at, 0);
    let ndc = vec2<f32>(screen.x * frame.screen.z * 2.0 - 1.0, 1.0 - screen.y * frame.screen.w * 2.0);
    let world = frame.inv_view_proj * vec4<f32>(ndc, depth, 1.0);
    return world.xyz / world.w;
}

// How far the opaque scene is from the camera at a pixel.
fn scene_distance(screen: vec2<f32>) -> f32 {
    return distance(scene_position(screen), frame.camera.xyz);
}

// Where direction `dir` is in the sky texture: around the horizon in u,
// from straight up (v 0) to straight down (v 1), squeezed towards the
// horizon where the sky changes fastest.
fn sky_uv(dir: vec3<f32>) -> vec2<f32> {
    let around = atan2(dir.z, dir.x) / (2.0 * PI) + 0.5;
    let up = asin(clamp(dir.y, -1.0, 1.0)) / (0.5 * PI);
    return vec2<f32>(around, 0.5 - 0.5 * sign(up) * sqrt(abs(up)));
}

// The direction at `uv` of the sky texture (the inverse of sky_uv).
fn sky_dir(uv: vec2<f32>) -> vec3<f32> {
    let around = (uv.x - 0.5) * 2.0 * PI;
    let s = (0.5 - uv.y) * 2.0;
    let up = sign(s) * s * s * 0.5 * PI;
    return vec3<f32>(cos(up) * cos(around), sin(up), cos(up) * sin(around));
}

// The sky's light from direction `dir` (towards the sky), without the sun
// itself. Black without an atmosphere.
fn sky_radiance(dir: vec3<f32>) -> vec3<f32> {
    return textureSampleLevel(sky_texture, sky_sampler, sky_uv(dir) * vec2<f32>(0.25, 128.0 / 1536.0), 0.0).rgb;
}

// `color` seen through the fog between the camera and `world`: height fog,
// thickest at the fog height and thinning out above it, and distance fog,
// none before its start and all fog past its end. Both take the sky's color
// behind them (or the fog color, without an atmosphere), and glow toward the
// sun. The sky itself takes only the height fog (`distance` false).
fn fog_along(color: vec3<f32>, world: vec3<f32>, distance: bool) -> vec3<f32> {
    let density = frame.fog.x;
    let far = select(0.0, frame.fog_far.y, distance);
    if density <= 0.0 && far <= 0.0 {
        return color;
    }
    let ray = world - frame.camera.xyz;
    let span = length(ray);
    let k = frame.fog.z;
    // The fog along the ray: density * exp(-(height - fog height) * k),
    // integrated from the camera to `world`.
    let start = density * exp(-(frame.camera.y - frame.fog.y) * k);
    let rise = ray.y * k;
    let along = select((1.0 - exp(-rise)) / rise, 1.0, abs(rise) < 1e-4);
    var seen = exp(-start * span * along);
    if far > 0.0 {
        let near = min(frame.fog_far.x, far - 1e-3);
        seen *= clamp((far - span) / (far - near), 0.0, 1.0);
    }
    let dir = ray / max(span, 1e-6);
    var fog_color = frame.fog_color.rgb;
    if frame.atmosphere.x > 0.0 {
        fog_color = sky_radiance(normalize(vec3<f32>(dir.x, max(dir.y, 0.03), dir.z)));
    }
    // Light scattered forward out of the sun (Henyey-Greenstein, 1 at focus 0).
    let g = frame.fog_far.w;
    let mu = dot(dir, frame.sun_dir.xyz);
    let phase = (1.0 - g * g) / pow(max(1.0 + g * g - 2.0 * g * mu, 1e-4), 1.5);
    var glow = frame.fog_far.z;
    // The shafts can only darken this pixel's glow, by at most what the glow
    // adds; where that is below what a display shows (looking away from the
    // sun, or through little fog), skip the march.
    let visible = max(max(frame.sun_color.r, frame.sun_color.g), frame.sun_color.b) * phase * glow * (1.0 - seen);
    if visible > SHAFT_VISIBLE && density > 0.0 && frame.fog.w > 0.0 {
        glow *= mix(1.0, fog_in_sun(world, start * span * along), frame.fog.w);
    }
    fog_color += frame.sun_color.rgb * phase * glow;
    return mix(fog_color, color, seen);
}

// Steps of the march through the fog for light shafts.
const SHAFT_STEPS: i32 = 16;
// The least glow (in linear color) the shafts are marched for: below it,
// darkening it to nothing changes the pixel by less than a display shows.
const SHAFT_VISIBLE: f32 = 0.002;

// Of the sunlight the fog between the camera and `world` scatters toward
// the camera, the share that isn't in shadow (`optical`: the fog's whole
// optical depth along the ray). Marched to the shadow distance, each step
// weighted by how thick the fog is there and how much of it gets through
// to the camera; past the shadows, the fog is all in sun. Shadows cut the
// glow into shafts.
fn fog_in_sun(world: vec3<f32>, optical: f32) -> f32 {
    let count = i32(frame.shadow.x);
    if count == 0 || frame.camera.w == 0.0 {
        return 1.0;
    }
    let ray = world - frame.camera.xyz;
    let span = max(length(ray), 1e-6);
    let reach = min(span, frame.shadow.w);
    let dt = reach / f32(SHAFT_STEPS);
    // A different offset each pixel and frame: the noise is fine grained,
    // and with temporal antialiasing it averages away.
    let clip = frame.view_proj * vec4<f32>(world, 1.0);
    let pixel = floor((clip.xy / clip.w * vec2<f32>(0.5, -0.5) + 0.5) * frame.screen.xy);
    let turn = f32(u32(frame.time.x * 60.0) % 64u);
    let jitter = fract(52.9829189 * fract(dot(pixel + 5.588238 * turn, vec2<f32>(0.06711056, 0.00583715))));
    // The sun's cascades are orthographic, so a point's shadow map coordinates
    // move linearly along the ray: project its two ends once per cascade and
    // interpolate, rather than projecting every step into every cascade.
    let end = frame.camera.xyz + ray * (reach / span);
    var start_at: array<vec3<f32>, 4>;
    var along: array<vec3<f32>, 4>;
    for (var c = 0; c < count; c++) {
        let a = (frame.sun_view_proj[c] * vec4<f32>(frame.camera.xyz, 1.0)).xyz;
        start_at[c] = a;
        along[c] = (frame.sun_view_proj[c] * vec4<f32>(end, 1.0)).xyz - a;
    }
    var through = 1.0;
    var lit = 0.0;
    for (var i = 0; i < SHAFT_STEPS; i++) {
        let f = (f32(i) + jitter) / f32(SHAFT_STEPS);
        let p = frame.camera.xyz + (end - frame.camera.xyz) * f;
        let thick = frame.fog.x * exp(min(-(p.y - frame.fog.y) * frame.fog.z, 20.0)) * dt;
        let scattered = through * (1.0 - exp(-thick));
        through *= exp(-thick);
        // The sharpest cascade that holds the point (all sun outside them).
        var sun = 1.0;
        for (var c = 0; c < count; c++) {
            let ndc = start_at[c] + along[c] * f;
            if max(abs(ndc.x), abs(ndc.y)) < 1.0 && ndc.z <= 1.0 {
                sun = textureSampleCompareLevel(shadow_map, shadow_sampler, vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5), c, ndc.z);
                break;
            }
        }
        lit += scattered * sun;
    }
    let all = 1.0 - exp(-optical);
    // Past the shadow distance: all in sun.
    let beyond = max(through - exp(-optical), 0.0);
    return clamp((lit + beyond) / max(all, 1e-5), 0.0, 1.0);
}

// `color` seen through all the fog between the camera and `world`.
fn fog(color: vec3<f32>, world: vec3<f32>) -> vec3<f32> {
    return fog_along(color, world, true);
}

// A pseudo-random number in 0..1 for a 2D point.
fn hash2(p: vec2<f32>) -> f32 {
    let q = fract(p * vec2<f32>(123.34, 456.21));
    let r = q + dot(q, q + 45.32);
    return fract(r.x * r.y);
}

// Smooth value noise in 0..1.
fn noise2(p: vec2<f32>) -> f32 {
    let i = floor(p);
    let f = fract(p);
    let u = f * f * (3.0 - 2.0 * f);
    let a = hash2(i);
    let b = hash2(i + vec2<f32>(1.0, 0.0));
    let c = hash2(i + vec2<f32>(0.0, 1.0));
    let d = hash2(i + vec2<f32>(1.0, 1.0));
    return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

// Five octaves of noise2, in 0..1.
fn fbm2(p: vec2<f32>) -> f32 {
    var sum = 0.0;
    var amplitude = 0.5;
    var q = p;
    for (var i = 0; i < 5; i++) {
        sum += noise2(q) * amplitude;
        q = q * 2.03 + vec2<f32>(17.0, 31.0);
        amplitude *= 0.5;
    }
    return sum / 0.96875;
}

fn direct_light(s: Surface, l: vec3<f32>, radiance: vec3<f32>) -> vec3<f32> {
    let n = s.normal;
    let v = s.view;
    let h = (l + v) / max(length(l + v), 1e-6);
    let n_l = max(dot(n, l), 0.0);
    let n_v = max(dot(n, v), 1e-4);
    let n_h = max(dot(n, h), 0.0);
    let v_h = max(dot(v, h), 0.0);

    let a = s.roughness * s.roughness;
    let a2 = a * a;
    let d_denom = n_h * n_h * (a2 - 1.0) + 1.0;
    let d = a2 / (PI * d_denom * d_denom);
    let k = (s.roughness + 1.0) * (s.roughness + 1.0) / 8.0;
    let g = n_v / (n_v * (1.0 - k) + k) * n_l / (n_l * (1.0 - k) + k);
    let f0 = mix(vec3<f32>(0.04), s.albedo, s.metallic);
    let f = f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - v_h, 5.0);
    let specular = d * g * f / max(4.0 * n_v * n_l, 1e-4);
    let diffuse = (vec3<f32>(1.0) - f) * (1.0 - s.metallic) * s.albedo / PI;
    return (diffuse + specular) * radiance * n_l;
}

// The froxel (16 x 9 screen cells, 24 slices of depth) holding a world
// position, or -1 when there are no clusters this frame.
fn cluster_cell(world: vec3<f32>) -> i32 {
    let clip = frame.cluster_view_proj * vec4<f32>(world, 1.0);
    if clip.w <= 0.0 || frame.cluster_depth.x <= 0.0 { return -1; }
    let xy = vec2<u32>(clamp(floor((clip.xy / clip.w * 0.5 + 0.5) * vec2<f32>(16.0, 9.0)), vec2<f32>(0.0), vec2<f32>(15.0, 8.0)));
    let z = u32(clamp(floor(log(max(clip.w, frame.cluster_depth.x) / frame.cluster_depth.x) * frame.cluster_depth.y), 0.0, 23.0));
    return i32((z * 9u + xy.y) * 16u + xy.x);
}

// Evaluate only the lights in the world position's froxel, using the same
// GGX BRDF as sunlight. Available to custom material shaders too.
fn punctual_light(s: Surface) -> vec3<f32> {
    let found = cluster_cell(s.world);
    if found < 0 { return vec3<f32>(0.0); }
    let cell = u32(found);
    let offset = cluster_word(cell * 2u);
    let count = cluster_word(cell * 2u + 1u);
    var sum = vec3<f32>(0.0);
    for (var i = 0u; i < count; i++) {
        let id = i32(cluster_word(offset + i));
        let pr = textureLoad(punctual_records, vec2<i32>(0, id), 0);
        let delta = pr.xyz - s.world;
        let d2 = dot(delta, delta);
        if d2 >= pr.w * pr.w { continue; }
        let l = delta / max(sqrt(d2), 1e-6);
        let cone = textureLoad(punctual_records, vec2<i32>(2, id), 0);
        var attenuation = 1.0;
        if cone.w > -1.0 {
            let inner = textureLoad(punctual_records, vec2<i32>(3, id), 0).x;
            let angle = dot(-l, cone.xyz);
            // A zero-width transition is an explicit hard cone.
            attenuation = select(smoothstep(cone.w, max(inner, cone.w + 1e-6), angle), select(0.0, 1.0, angle >= cone.w), inner == cone.w);
        }
        let window = max(1.0 - (d2 / (pr.w * pr.w)) * (d2 / (pr.w * pr.w)), 0.0);
        attenuation *= window * window / max(d2, 1.0);
        let shadow_info = textureLoad(punctual_records, vec2<i32>(3, id), 0);
        if shadow_info.y >= 0.0 && attenuation > 0.0 {
            attenuation *= punctual_shadow(i32(shadow_info.y), shadow_info.z, pr.xyz, s.world, s.normal, cone.w <= -1.0);
        }
        let radiance = textureLoad(punctual_records, vec2<i32>(1, id), 0).rgb * attenuation;
        sum += direct_light(s, l, radiance);
    }
    return sum;
}
// How much of a point or spot light at `light` reaches `world` (0 in its
// shadow, 1 lit), from its shadow views starting at `first`: a spot light's
// one, or a point light's six cube faces (+X, -X, +Y, -Y, +Z, -Z). `texel`
// is a texel's world size per unit of distance from the light.
fn punctual_shadow(first: i32, texel: f32, light: vec3<f32>, world: vec3<f32>, n: vec3<f32>, point: bool) -> f32 {
    if f32(first) >= frame.light_shadow.x {
        return 1.0;
    }
    // Looking up a little off the surface keeps it from shadowing itself;
    // texels grow with distance from the light.
    let p = world + n * (frame.light_shadow.z * texel * distance(world, light));
    var view = first;
    if point {
        let v = p - light;
        let a = abs(v);
        if a.x >= a.y && a.x >= a.z {
            view += select(1, 0, v.x > 0.0);
        } else if a.y >= a.z {
            view += select(3, 2, v.y > 0.0);
        } else {
            view += select(5, 4, v.z > 0.0);
        }
    }
    let clip = frame.light_view_proj[view] * vec4<f32>(p, 1.0);
    if clip.w <= 0.0 {
        return 1.0;
    }
    let ndc = clip.xyz / clip.w;
    if abs(ndc.x) > 1.0 || abs(ndc.y) > 1.0 || ndc.z > 1.0 {
        return 1.0;
    }
    // Into the view's atlas tile; filtering stays half a texel inside it,
    // off its neighbors.
    let rect = frame.light_rect[view];
    let uv = rect.xy + vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5) * rect.z;
    let step = frame.light_shadow.y;
    let lo = rect.xy + 0.5 * step;
    let hi = rect.xy + rect.z - 0.5 * step;
    var lit = 0.0;
    for (var y = -1; y <= 1; y++) {
        for (var x = -1; x <= 1; x++) {
            let at = clamp(uv + vec2<f32>(f32(x), f32(y)) * step, lo, hi);
            lit += textureSampleCompareLevel(light_shadow_map, shadow_sampler, at, ndc.z);
        }
    }
    return lit / 9.0;
}

fn cluster_word(i: u32) -> u32 {
    return textureLoad(punctual_clusters, vec2<i32>(i32(i % 1024u), i32(i / 1024u)), 0).x;
}

fn cube_uv(d: vec3<f32>) -> vec3<f32> {
    let a = abs(d); var f = 0.0; var p = vec2<f32>(0.0);
    if a.x >= a.y && a.x >= a.z {
        if d.x > 0.0 { f = 0.0; p = vec2<f32>(-d.z, -d.y) / a.x; }
        else { f = 1.0; p = vec2<f32>(d.z, -d.y) / a.x; }
    } else if a.y >= a.z {
        if d.y > 0.0 { f = 2.0; p = vec2<f32>(d.x, d.z) / a.y; }
        else { f = 3.0; p = vec2<f32>(d.x, -d.z) / a.y; }
    } else {
        if d.z > 0.0 { f = 4.0; p = vec2<f32>(d.x, -d.y) / a.z; }
        else { f = 5.0; p = vec2<f32>(-d.x, -d.y) / a.z; }
    }
    return vec3<f32>(p * 0.5 + 0.5, f);
}

fn environment_sample(slot: f32, direction: vec3<f32>, roughness: f32, diffuse: bool) -> vec3<f32> {
    let uv = cube_uv(direction);
    if diffuse {
        let pixel = vec2<f32>(800.0 + uv.z * 16.0, 128.0 + slot * 256.0) + clamp(uv.xy * 16.0, vec2<f32>(0.5), vec2<f32>(15.5));
        return textureSampleLevel(sky_texture, sky_sampler, pixel / vec2<f32>(1024.0, 1536.0), 0.0).rgb;
    }
    let level = clamp(roughness, 0.0, 1.0) * 7.0;
    let low = floor(level); let high = min(low + 1.0, 7.0);
    let lo_size = 128.0 / exp2(low); let hi_size = 128.0 / exp2(high);
    let lo = vec2<f32>(uv.z * lo_size, 128.0 + slot * 256.0 + 256.0 - lo_size * 2.0) + clamp(uv.xy * lo_size, vec2<f32>(0.5), vec2<f32>(lo_size - 0.5));
    let hi = vec2<f32>(uv.z * hi_size, 128.0 + slot * 256.0 + 256.0 - hi_size * 2.0) + clamp(uv.xy * hi_size, vec2<f32>(0.5), vec2<f32>(hi_size - 0.5));
    return mix(textureSampleLevel(sky_texture, sky_sampler, lo / vec2<f32>(1024.0, 1536.0), 0.0).rgb,
        textureSampleLevel(sky_texture, sky_sampler, hi / vec2<f32>(1024.0, 1536.0), 0.0).rgb, fract(level));
}
fn probe_weight(p: ReflectionVolume, world: vec3<f32>) -> f32 {
    if p.center.w <= 0.0 { return 0.0; }
    let edge = p.size.xyz - abs(world - p.center.xyz);
    let distance = min(edge.x, min(edge.y, edge.z));
    if distance <= 0.0 { return 0.0; }
    return clamp(distance / max(p.size.w, 0.00001), 0.0, 1.0);
}
fn probe_direction(p: ReflectionVolume, world: vec3<f32>, ray: vec3<f32>) -> vec3<f32> {
    if p.params.y == 0.0 { return ray; }
    let local = world - p.center.xyz;
    let safe_ray = select(vec3<f32>(0.000001), ray, abs(ray) > vec3<f32>(0.000001));
    let times = (select(-p.size.xyz, p.size.xyz, safe_ray > vec3<f32>(0.0)) - local) / safe_ray;
    let t = min(times.x, min(times.y, times.z));
    return local + ray * t;
}
// The opaque pass's screen-space reflection at this pixel (see ssr.wgsl):
// what the reflected ray hit on screen, and how much it counts. Zero
// elsewhere, and where the ray found nothing.
var<private> screen_reflection: vec4<f32> = vec4<f32>(0.0);
// Global illumination's bounced light at this pixel (see gi.wgsl): rgb, and
// how much it replaces the ambient light in a. Zero without it.
var<private> bounced_light: vec4<f32> = vec4<f32>(0.0);

fn environment_light(s: Surface, f0: vec3<f32>) -> vec3<f32> {
    let ray = reflect(-s.view, s.normal);
    // The two smallest volumes around this pixel: a room inside a bigger one
    // comes first. Each takes its fade's share of what is left; the sky gets the rest.
    var first = 4u; var second = 4u; var size1 = 1.0e30; var size2 = 1.0e30;
    for (var i = 0u; i < 4u; i++) {
        let p = frame.probes[i];
        if probe_weight(p, s.world) > 0.0 {
            let size = p.size.x * p.size.y * p.size.z;
            if size < size1 { second = first; size2 = size1; first = i; size1 = size; }
            else if size < size2 { second = i; size2 = size; }
        }
    }
    var remaining = 1.0;
    var diffuse = vec3<f32>(0.0);
    var specular = vec3<f32>(0.0);
    for (var j = 0u; j < 2u; j++) {
        let index = select(first, second, j == 1u);
        if index < 4u {
            let p = frame.probes[index];
            let share = remaining * probe_weight(p, s.world) * p.params.x;
            remaining *= 1.0 - probe_weight(p, s.world);
            diffuse += environment_sample(p.center.w, s.normal, 0.0, true) * share;
            specular += environment_sample(p.center.w, probe_direction(p, s.world, ray), s.roughness, false) * share;
        }
    }
    diffuse += environment_sample(0.0, s.normal, 0.0, true) * remaining;
    specular += environment_sample(0.0, ray, s.roughness, false) * remaining;
    // Bounced light replaces the ambient light where the probes cover this
    // point. Where it's darker (indoors), reflections of the sky darken too.
    if bounced_light.a > 0.0 {
        let luma = vec3<f32>(0.2126, 0.7152, 0.0722);
        let dimmer = clamp(dot(bounced_light.rgb, luma) / max(dot(diffuse, luma), 1e-4), 0.0, 1.0);
        specular *= mix(1.0, dimmer, bounced_light.a);
        diffuse = mix(diffuse, bounced_light.rgb, bounced_light.a);
    }
    // What's on screen replaces the environment where the reflected ray hit
    // it, with filtered reflections on glossy surfaces.
    specular = mix(specular, screen_reflection.rgb, screen_reflection.a * (1.0 - smoothstep(0.4, 0.7, s.roughness)));
    let nv = max(dot(s.normal, s.view), 0.0);
    let uv = (vec2<f32>(800.0, 0.0) + clamp(vec2<f32>(nv, s.roughness) * 128.0, vec2<f32>(0.5), vec2<f32>(127.5))) / vec2<f32>(1024.0, 1536.0);
    let brdf = textureSampleLevel(sky_texture, sky_sampler, uv, 0.0).rg;
    return (diffuse * s.albedo * (1.0 - s.metallic) + specular * (f0 * brdf.x + brdf.y)) * s.occlusion;
}

// The sun's shadow, for material shaders, screen effects and compute passes.
// Where `world` falls in shadow cascade `i`: the shadow map uv, the depth
// to compare, and how near the edge of the cascade's box it is (0 at the
// center, 1 at the edge, more outside).
fn cascade_coords(i: i32, world: vec3<f32>, n: vec3<f32>) -> vec4<f32> {
    // Looking up a little off the surface keeps it from shadowing itself.
    let offset = n * frame.shadow.z * frame.cascade_texel[i];
    let clip = frame.sun_view_proj[i] * vec4<f32>(world + offset, 1.0);
    let ndc = clip.xyz / clip.w;
    let edge = max(max(abs(ndc.x), abs(ndc.y)), select(0.0, 2.0, ndc.z > 1.0));
    return vec4<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, ndc.z, edge);
}

// How much light cascade `i` lets through at `c` (from `cascade_coords`),
// softened by `Lighting::shadow_filter`: one bilinear lookup (2x2 texels),
// four half a texel apart (3x3), or nine a texel apart (4x4).
fn cascade_light(i: i32, c: vec4<f32>) -> f32 {
    let soft = frame.light_shadow.w;
    if soft < 1.5 {
        return textureSampleCompareLevel(shadow_map, shadow_sampler, c.xy, i, c.z);
    }
    if soft < 2.5 {
        var four = 0.0;
        for (var y = -1; y <= 1; y += 2) {
            for (var x = -1; x <= 1; x += 2) {
                let at = c.xy + vec2<f32>(f32(x), f32(y)) * 0.5 * frame.shadow.y;
                four += textureSampleCompareLevel(shadow_map, shadow_sampler, at, i, c.z);
            }
        }
        return four / 4.0;
    }
    var lit = 0.0;
    for (var y = -1; y <= 1; y++) {
        for (var x = -1; x <= 1; x++) {
            let at = c.xy + vec2<f32>(f32(x), f32(y)) * frame.shadow.y;
            lit += textureSampleCompareLevel(shadow_map, shadow_sampler, at, i, c.z);
        }
    }
    return lit / 9.0;
}

// How much sun reaches `world` (0 in shadow, 1 lit). The first cascade
// whose box holds the point gives the sharpest shadow; near that box's edge
// it fades into the next cascade, so the step between them doesn't show.
// Only the cascades used are sampled.
fn sunlight(world: vec3<f32>, n: vec3<f32>) -> f32 {
    let count = i32(frame.shadow.x);
    // Towards the shadow distance, shadows fade out.
    let fade = smoothstep(0.85, 1.0, distance(world, frame.camera.xyz) / frame.shadow.w);
    if count == 0 || fade >= 1.0 {
        return 1.0;
    }
    // The 3x3 filter reaches one texel further out than the point.
    let inside = 1.0 - 2.0 * frame.shadow.y;
    var lit = 1.0;
    for (var i = 0; i < count; i++) {
        let c = cascade_coords(i, world, n);
        if c.w < inside {
            lit = cascade_light(i, c);
            let blend = smoothstep(0.85, 1.0, c.w / inside);
            if blend > 0.0 && i + 1 < count {
                lit = mix(lit, cascade_light(i + 1, cascade_coords(i + 1, world, n)), blend);
            }
            break;
        }
    }
    return mix(lit, 1.0, fade);
}