concinnity-core 0.19.0

Runtime vocabulary for the Concinnity engine: GPU layouts, ECS components, registry, CPU kernels
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
// Water surface pass: single source for every backend.
//
// The second producer of the engine's transparent pass (`PassId::Transparent`,
// after the SSR resolve and before TAA), alongside `glass.slang`. Where a glass
// pane is a flat quad the vertex stage only projects, a water surface is a
// tessellated XZ grid (see geometry::water_grid) the vertex stage displaces by a
// sum of Gerstner waves, taking the world-space normal from the analytic
// derivatives of that sum. The fragment stage then composites, in order:
//
//   * Refraction - the pre-transparent scene snapshot at a normal-perturbed
//                  screen UV, so the seabed bends under the waves.
//   * Tint       - shallow to deep by the water-column thickness the main depth
//                  gives, an exponential falloff over `depth_falloff`.
//   * Foam       - a soft band where the seabed is just below the surface.
//   * Reflection - see below.
//   * Fresnel    - Schlick with a water-vs-air F0 of 0.02, shaped by
//                  `fresnel_power`.
//
// One fragment entry per compile, selected by a define. Only the reflection
// differs between them, exactly as in glass.slang:
//
//   default                   - the sharp planar reflection when this surface
//                               has a slot (screen UV perturbed by the wave
//                               normal, so the mirror ripples), else the
//                               box-projected reflection-probe set, else the sky
//                               prefilter cube, else a hand-tuned sky gradient.
//   WATER_RT                  - that same planar reflection where the surface has
//                               a slot, else a per-pixel reflection ray against
//                               the scene acceleration structure (the shared
//                               RT_TRACE fragment) off the wave surface point, so
//                               `R` follows the waves; the miss falls back to the
//                               same probe / sky chain.
//   WATER_RT with RT_TEXTURED - the same trace, with reflected hits taking their
//                               albedo / normal / emissive maps from the
//                               bindless pool.
//
// The mirror outranks the trace for water because a water surface IS a plane:
// one mirrored scene render resolves it exactly, where a trace off the
// per-fragment wave normal is hypersensitive at grazing angles and drops to the
// probe / sky chain wherever it misses. A glass pane does the opposite (see
// glass.slang), which is why only this file reads `planar` on the RT path.
//
// USE_MSAA is a HOST difference rather than a target one: Vulkan reads the
// multisampled main depth while Metal and DirectX read the resolved copy.
//
// Every binding here is deliberately the one glass.slang declares at the same
// slot, because the two share a transparent pass: on DirectX that lets one root
// signature serve both pipelines, on Vulkan one set of descriptor set layouts,
// and on Metal the encoder binds the shared reflection inputs once for the whole
// pass. A slot may not move on one side alone.

#ifndef POOL_SIZE
#define POOL_SIZE 1024
#endif
#ifndef MAX_PROBES
#define MAX_PROBES 8
#endif
#ifndef USE_MSAA
#define USE_MSAA 0
#endif

// Waves summed per surface. Mirrors `MAX_WATER_WAVES` in concinnity-asset and
// `WATER_MAX_WAVES` in `core::render`'s uniforms; the array length is part of
// the `WaterParams` layout, so it is a constant here rather than a define.
static const uint MAX_WATER_WAVES = 4u;

{PROBE_TYPES}

#ifdef WATER_RT
{RT_TYPES}
#endif

// Per-frame view shared by every transparent draw. Layout matches
// `TransparentView` / the TransparentViewBlock UBO (160 B). The same
// declaration glass.slang carries -- one host block feeds both.
struct TransparentView
{
    float4x4 vp;       // world -> clip (jittered when TAA is on)
    float4x4 inv_vp;   // clip -> world
    float4 camera_pos; // xyz: world-space camera
    float2 viewport;   // attachment dimensions in pixels
    float time;        // seconds since startup, driving the wave phase
    // Mips in the sky prefilter cube; 0 = no EnvironmentMap bound, and the
    // reflection falls through to the sky gradient.
    float prefilter_mip_count;
};

// One Gerstner wave, packed into two float4 lanes so the CPU `[f32; 4]` pair is
// byte-identical on every target. Matches `WaterWaveGpu` (32 B).
struct WaterWave
{
    float4 dir_amp_wave;    // [direction.x, direction.y, amplitude, wavelength]
    float4 speed_steep_pad; // [speed, steepness, _, _]
};

// Per-surface tunables. Layout matches `WaterParams` / the WaterParamsBlock UBO
// (224 B); the vec3 fields are float4 so MSL's 16-byte constant-buffer float3
// cannot desynchronise them from the CPU struct.
struct WaterParams
{
    float4 centre;         // world-space surface centre
    float4 deep_colour;    // linear RGB at full column depth
    float4 shallow_colour; // linear RGB at the shore
    float depth_falloff;
    float foam_width;
    float foam_intensity;
    float fresnel_power;
    float roughness;
    float refraction_strength;
    uint wave_count;
    // Aligns `waves` to 16, which its float4 lanes require.
    float _pad;
    WaterWave waves[MAX_WATER_WAVES];
    // [strength, distortion, _, _]. `strength > 0.5` selects the sharp planar
    // reflection over the trace / probe / sky cube; `distortion` scales the
    // wave-normal perturbation of its screen-UV lookup. Set on both paths: the
    // host raises it whenever this surface holds a mirror slot and the planar
    // pass ran, and the RT entry honours it too.
    float4 planar;
};

// ---- Resource bindings ----

#ifdef DXIL_ABI

// Pinned to the root signatures in `directx/transparent.rs`, which glass shares;
// b1 is visible to every stage there because the water vertex stage reads the
// wave table out of it.
ConstantBuffer<TransparentView> view : register(b0);
ConstantBuffer<WaterParams> params : register(b1);
Texture2D<float4> scene_color : register(t0);
#if USE_MSAA
Texture2DMS<float> scene_depth : register(t1);
#else
Texture2D<float> scene_depth : register(t1);
#endif
TextureCube<float4> prefilter_cube : register(t2);
Texture2D<float4> planar_reflection : register(t3);
SamplerState post_samp : register(s0);
SamplerState cube_sampler : register(s2);

float4 scene_sample(float2 uv) { return scene_color.Sample(post_samp, uv); }
float4 planar_sample(float2 uv) { return planar_reflection.Sample(post_samp, uv); }
float3 prefilter_level(float3 dir, float lod)
{
    return prefilter_cube.SampleLevel(cube_sampler, dir, lod).rgb;
}

#else

// Metal buffer(5) / buffer(6): the shared per-frame view and the per-surface
// params, both written with setBytes by the transparent encoder.
[[vk::binding(0, 0)]] ConstantBuffer<TransparentView> view : register(b5);
[[vk::binding(0, 1)]] ConstantBuffer<WaterParams> params : register(b6);

// Declaration order is the Metal texture index, and these are the transparent
// pass's shared slots: the scene snapshot at 0, the resolved depth at 1, the sky
// prefilter cube at 2, the probe cubes at 3..2+MAX_PROBES, and this surface's
// planar resolve at 11.
[[vk::binding(1, 0)]] Sampler2D<float4> scene_color;
#if USE_MSAA
[[vk::binding(2, 0)]] Texture2DMS<float> scene_depth;
#else
[[vk::binding(2, 0)]] Texture2D<float> scene_depth;
#endif
[[vk::binding(5, 2)]] SamplerCube<float4> prefilter_cube;

float4 scene_sample(float2 uv) { return scene_color.Sample(uv); }
float3 prefilter_level(float3 dir, float lod) { return prefilter_cube.SampleLevel(dir, lod).rgb; }

#endif

// The reflection-probe set + cube array, from the forward global set: a pond
// reflects the same local scene capture the forward IBL specular and the SSR /
// RT miss fallback use, rather than only the foreign sky cube.
#ifdef DXIL_ABI
ConstantBuffer<ProbeSet> probe_set : register(b4);
// The array spans MAX_PROBES registers from its base, so the RT variant moves it
// clear of the ray-tracing SRVs at t4..t10 rather than starting at t7.
#ifdef WATER_RT
TextureCube<float4> probe_cubes[MAX_PROBES] : register(t20);
#else
TextureCube<float4> probe_cubes[MAX_PROBES] : register(t7);
#endif

float3 probe_cube_sample_bias(uint i, float3 dir, float lod)
{
    return probe_cubes[i].SampleBias(cube_sampler, dir, lod).rgb;
}
#else
[[vk::binding(7, 2)]] ConstantBuffer<ProbeSet> probe_set : register(b7);
[[vk::binding(8, 2)]] SamplerCube<float4> probe_cubes[MAX_PROBES];

float3 probe_cube_sample_bias(uint i, float3 dir, float lod)
{
    return probe_cubes[i].SampleBias(dir, lod).rgb;
}
#endif
#define PROBE_SET probe_set

#ifndef DXIL_ABI
// This surface's planar reflection target, bound per surface and sampled at the
// fragment's screen UV when `planar.x > 0.5`. Declared after the probe array so
// it lands on Metal's texture(11); a surface with no planar slot binds a valid
// stand-in and never samples it. Read by both entries: the RT one takes the
// mirror over its own trace wherever a slot exists.
[[vk::binding(1, 1)]] Sampler2D<float4> planar_reflection;

float4 planar_sample(float2 uv) { return planar_reflection.Sample(uv); }
#endif

#ifdef WATER_RT
// The ray-tracing scene resources, at the slots glass.slang uses so the inputs
// the transparent encoder binds once are valid for both. On Metal they ride the
// pass's otherwise-free fragment buffers (0..4 and 8..10, since 5/6/7 are the
// view, the params and the probe set); on Vulkan they are a set of their own,
// past the view / params / global sets; on DirectX they follow the registers the
// pass already occupies.
#ifdef DXIL_ABI

ConstantBuffer<RtParams> rt_params : register(b5);
RaytracingAccelerationStructure scene_tlas : register(t4);
ByteAddressBuffer verts : register(t5);
ByteAddressBuffer indices : register(t6);
ByteAddressBuffer sverts : register(t8);
ByteAddressBuffer sidx : register(t9);
StructuredBuffer<RtGeomEntry> geom : register(t10);

float vert_float(uint i) { return asfloat(verts.Load(i * 4u)); }
float svert_float(uint i) { return asfloat(sverts.Load(i * 4u)); }
uint index_at(uint o) { return indices.Load(o * 4u); }
uint skinned_index_word(uint w) { return sidx.Load(w * 4u); }

#else

[[vk::binding(0, 3)]] ConstantBuffer<RtParams> rt_params : register(b0);
[[vk::binding(1, 3)]] RaytracingAccelerationStructure scene_tlas : register(t4);
[[vk::binding(2, 3)]] StructuredBuffer<RtGeomEntry> geom : register(t3);
[[vk::binding(3, 3)]] StructuredBuffer<float> verts : register(t1);
[[vk::binding(4, 3)]] StructuredBuffer<uint> indices : register(t2);
[[vk::binding(5, 3)]] StructuredBuffer<float> sverts : register(t8);
[[vk::binding(6, 3)]] StructuredBuffer<uint> sidx : register(t9);

float vert_float(uint i) { return verts[i]; }
float svert_float(uint i) { return sverts[i]; }
uint index_at(uint o) { return indices[o]; }
uint skinned_index_word(uint w) { return sidx[w]; }

#endif

#ifdef RT_TEXTURED
// The bindless pool. Metal keeps it at buffer(10): buffer(7), where the main
// pass puts it, is the probe set in the transparent pass.
uint nonuniform_index(uint i)
{
    __target_switch
    {
    case metal:
        return i;
    default:
        return NonUniformResourceIndex(i);
    }
}

#if defined(METAL_ABI)
struct TexturePool
{
    Texture2D<float4> tex_pool[POOL_SIZE];
};
ParameterBlock<TexturePool> pool;
SamplerState pool_sampler;

float3 pool_sample_level0(uint idx, float2 uv)
{
    return pool.tex_pool[nonuniform_index(idx)].SampleLevel(pool_sampler, uv, 0.0).rgb;
}
#elif defined(DXIL_ABI)
Texture2D<float4> tex_pool[] : register(t0, space1);
SamplerState pool_sampler : register(s1);

float3 pool_sample_level0(uint idx, float2 uv)
{
    return tex_pool[nonuniform_index(idx)].SampleLevel(pool_sampler, uv, 0.0).rgb;
}
#else
[[vk::binding(1, 4)]] Sampler2D<float4> tex_pool[POOL_SIZE];

float3 pool_sample_level0(uint idx, float2 uv)
{
    return tex_pool[nonuniform_index(idx)].SampleLevel(uv, 0.0).rgb;
}
#endif
#endif
#endif

{PROBE_COMMON}

#ifdef WATER_RT
{RT_TRACE}
#endif

// ---- Stage interface ----

struct WaterVertexIn
{
    [[vk::location(0)]] float3 pos : POSITION;
};

struct WaterVertexOut
{
    [[vk::location(0)]] float3 world_pos : TEXCOORD0;
    [[vk::location(1)]] float3 world_normal : TEXCOORD1;
    float4 position : SV_Position;
};

// The displaced surface point and its normal for one flat-rest XZ position.
struct WaterPoint
{
    float3 pos;
    float3 normal;
};

// Sum up to MAX_WATER_WAVES Gerstner waves at a flat-rest XZ position. Each wave
// is a horizontal pinch plus a vertical sinusoid; the analytic partials against
// (x, z) give the world-space normal at the displaced point. Reference: NVIDIA,
// "Effective Water Simulation from Physical Models", GPU Gems 1.
WaterPoint gerstner_displace(float2 rest_xz, float time)
{
    float3 displaced = float3(rest_xz.x, 0.0, rest_xz.y);
    float3 binormal = float3(1.0, 0.0, 0.0); // dP/dx
    float3 tangent = float3(0.0, 0.0, 1.0);  // dP/dz

    uint count = min(params.wave_count, MAX_WATER_WAVES);
    for (uint i = 0u; i < count; i++)
    {
        float2 dir = normalize(params.waves[i].dir_amp_wave.xy);
        float amp = params.waves[i].dir_amp_wave.z;
        float wavelen = max(params.waves[i].dir_amp_wave.w, 1e-3);
        float speed = params.waves[i].speed_steep_pad.x;
        float steep = saturate(params.waves[i].speed_steep_pad.y);

        float k = 2.0 * 3.14159265358979 / wavelen;
        float phase = k * dot(dir, rest_xz) - speed * k * time;
        float c = cos(phase);
        float s = sin(phase);

        // Steepness normalised by wave count so summed crests cannot pinch past
        // self-intersection.
        float q = steep / (k * amp * max(float(count), 1.0));

        displaced.x += q * amp * dir.x * c;
        displaced.z += q * amp * dir.y * c;
        displaced.y += amp * s;

        float wa = k * amp;
        binormal.x += -q * dir.x * dir.x * wa * s;
        binormal.y += dir.x * wa * c;
        binormal.z += -q * dir.x * dir.y * wa * s;

        tangent.x += -q * dir.x * dir.y * wa * s;
        tangent.y += dir.y * wa * c;
        tangent.z += -q * dir.y * dir.y * wa * s;
    }

    WaterPoint p;
    p.pos = displaced;
    p.normal = normalize(cross(tangent, binormal));
    return p;
}

[shader("vertex")]
WaterVertexOut water_vertex(WaterVertexIn v)
{
    // The grid is built centred on the origin in the XZ plane, so the surface
    // centre rides in through the params rather than the vertex buffer.
    float2 rest_xz = float2(v.pos.x + params.centre.x, v.pos.z + params.centre.z);
    WaterPoint p = gerstner_displace(rest_xz, view.time);
    p.pos.y += params.centre.y;

    WaterVertexOut o;
    o.world_pos = p.pos;
    o.world_normal = p.normal;
    o.position = mul(view.vp, float4(p.pos, 1.0));
    return o;
}

// Depth stored at this pixel by the main pass, for the manual occlusion test and
// the water-column thickness.
float water_scene_depth(int2 pixel)
{
#if USE_MSAA
    return scene_depth.Load(pixel, 0);
#else
    return scene_depth.Load(int3(pixel, 0));
#endif
}

// A screen-space pixel coordinate clamped into the attachment.
int2 water_clamp_pixel(float2 pixel_xy)
{
    int2 last = int2(max(view.viewport, float2(1.0))) - int2(1, 1);
    return clamp(int2(pixel_xy), int2(0, 0), last);
}

// Linear camera distance to the scene point a screen NDC position and its stored
// non-linear depth describe. Gives the water column thickness when differenced
// against the distance to the surface itself.
float water_scene_distance(float2 ndc_xy, float depth01)
{
    float4 world = mul(view.inv_vp, float4(ndc_xy, depth01, 1.0));
    return distance(world.xyz / world.w, view.camera_pos.xyz);
}

// The surface at this fragment: the wave normal, the fragment's screen UV, and
// everything below the waterline -- the refracted scene, tinted by column depth
// and brightened to foam where the seabed is close.
struct WaterSurfacePoint
{
    float3 normal;
    float2 frag_uv;
    float3 below;
};

WaterSurfacePoint water_surface(WaterVertexOut i)
{
    WaterSurfacePoint s;
    s.normal = normalize(i.world_normal);

    float2 vp_dim = max(view.viewport, float2(1.0));
    s.frag_uv = i.position.xy / vp_dim;

    // Refraction: perturb the screen lookup by the wave normal's XZ so the
    // seabed bends under the waves.
    float2 refract_uv = clamp(s.frag_uv + s.normal.xz * params.refraction_strength,
                              float2(0.001), float2(0.999));
    float3 refracted = scene_sample(refract_uv).rgb;

    // Read the main depth at the REFRACTED pixel, so the thickness matches the
    // texel just sampled: a refraction that bends into a foreground edge would
    // otherwise be tinted as if that edge were underwater.
    float scene_depth01 = water_scene_depth(water_clamp_pixel(refract_uv * vp_dim));
    float2 ndc_xy = float2(s.frag_uv.x * 2.0 - 1.0, -(s.frag_uv.y * 2.0 - 1.0));
    float scene_dist = water_scene_distance(ndc_xy, scene_depth01);
    float water_dist = distance(i.world_pos, view.camera_pos.xyz);
    float water_depth = max(scene_dist - water_dist, 0.0);

    // Tint: exponential shallow to deep blend over `depth_falloff` metres.
    float depth_t = 1.0 - exp(-water_depth / max(params.depth_falloff, 1e-3));
    float3 tinted = lerp(params.shallow_colour.rgb, params.deep_colour.rgb, depth_t);
    float3 below = lerp(refracted * params.shallow_colour.rgb, tinted, depth_t);

    // Foam: a soft band where the seabed is just below the surface, which is
    // both the shoreline and any intersection line with standing geometry.
    float foam_t = saturate(1.0 - water_depth / max(params.foam_width, 1e-3));
    s.below = lerp(below, float3(1.0), foam_t * foam_t * params.foam_intensity);
    return s;
}

// The mirror render for this surface, at the fragment's own screen UV perturbed
// by the wave normal. The planar render mirrors the scene across the surface's
// rest plane, so it lands exactly under the reflector; perturbing that lookup is
// what turns a flat mirror back into rippling water.
float3 water_planar_reflection(WaterSurfacePoint s)
{
    float2 uv = clamp(s.frag_uv + s.normal.xz * params.planar.y,
                      float2(0.001), float2(0.999));
    return planar_sample(uv).rgb;
}

// The prefilter mip a surface of this roughness reflects at. Blurrier water
// reads a coarser cube level. The mip count is per-frame state, so it comes from
// the pass's view block rather than the per-surface params.
float water_reflection_mip()
{
    return saturate(params.roughness) * max(view.prefilter_mip_count - 1.0, 0.0);
}

// The reflection a surface with no sharp source falls back to: the local
// box-projected probe set where a probe actually covers this point, else the sky
// prefilter cube, else a hand-tuned vertical sky gradient (bluer overhead, paler
// at the horizon) so an environment-less world still reads as water rather than
// as flat tinted glass.
//
// The coverage test matters more here than anywhere else: a pool routinely
// stretches past every probe box in the world, and the probe set's own
// out-of-box fallback would hand the whole surface one foreign capture.
float3 water_environment(float3 world_pos, float3 r)
{
    float mip = water_reflection_mip();
    if (probe_set.count > 0u && probe_set_covers(world_pos))
    {
        return probe_set_specular(world_pos, r, mip);
    }
    if (view.prefilter_mip_count > 0.5)
    {
        return prefilter_level(r, mip);
    }
    float horizon = saturate(r.y * 0.5 + 0.5);
    return lerp(float3(0.55, 0.62, 0.7), float3(0.25, 0.45, 0.7), horizon);
}

// Schlick Fresnel mix of the reflection over everything below the waterline.
// F0 = 0.02 is the water-vs-air value; `fresnel_power` shapes the falloff, so a
// low power keeps the reflection strong head-on instead of only at grazing.
// Alpha 1: water fully covers what it drew over, and the pipeline's straight
// alpha blend leaves the composited colour as-is.
float4 water_resolve(WaterSurfacePoint s, float3 view_dir, float3 reflection)
{
    float n_dot_v = saturate(dot(s.normal, view_dir));
    float fresnel = 0.02 + 0.98 * pow(1.0 - n_dot_v, max(params.fresnel_power, 1e-3));
    return float4(lerp(s.below, reflection, fresnel), 1.0);
}

// True where nearer opaque geometry occludes the surface. The transparent pass
// binds no depth attachment, so the test is manual; every backend rasterised
// this depth under the same viewport convention the main pass used, so the
// fragment position lines up with the stored texel.
bool water_occluded(WaterVertexOut i)
{
    return water_scene_depth(water_clamp_pixel(i.position.xy)) < i.position.z;
}

#ifdef WATER_RT

[shader("fragment")]
float4 water_rt_fragment(WaterVertexOut i) : SV_Target
{
    if (water_occluded(i))
    {
        discard;
    }
    float3 view_dir = normalize(view.camera_pos.xyz - i.world_pos);
    WaterSurfacePoint s = water_surface(i);

    float3 reflection;
    if (params.planar.x > 0.5)
    {
        reflection = water_planar_reflection(s);
    }
    // No mirror plane for this surface, so trace instead. The per-fragment
    // Gerstner normal makes `R` vary across the surface, so the traced
    // reflection follows the waves rather than mirroring one flat plane.
    else
    {
        float3 r = reflect(-view_dir, s.normal);
        if (!rt_trace_reflection(i.world_pos + s.normal * 0.02, r,
                                 view.prefilter_mip_count > 0.5,
                                 view.prefilter_mip_count - 1.0, reflection))
        {
            reflection = water_environment(i.world_pos, r);
        }
    }
    return water_resolve(s, view_dir, reflection);
}

#else

[shader("fragment")]
float4 water_fragment(WaterVertexOut i) : SV_Target
{
    if (water_occluded(i))
    {
        discard;
    }
    float3 view_dir = normalize(view.camera_pos.xyz - i.world_pos);
    WaterSurfacePoint s = water_surface(i);

    float3 reflection;
    if (params.planar.x > 0.5)
    {
        reflection = water_planar_reflection(s);
    }
    else
    {
        reflection = water_environment(i.world_pos, reflect(-view_dir, s.normal));
    }
    return water_resolve(s, view_dir, reflection);
}

#endif