Fry3DX 0.4: an easy 3D modeller

Shapes with live settings, shape editing, knife and hole tools, drawing
shapes onto other objects, combining objects (add / subtract / overlap),
copies, thickness, painting with stickers and layers, a Layout tab, a
Print tab with checks, packed .fry files with autosave, export to GLB,
glTF, FBX, OBJ, 3MF, STL and PLY, folders in Layers, its own crash
screen, and a website for GitHub Pages in docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
This commit is contained in:
Nexoniarz 2026-10-19 21:18:35 +02:00
commit f3e8eca87b
135 changed files with 23423 additions and 0 deletions

32
engine/shaders/adapt.frag Normal file
View file

@ -0,0 +1,32 @@
#version 460
// Eye adaptation (1×1): the scene's average brightness (geometric mean of 16 taps of the
// smallest bloom level), turned into an exposure that keeps a mid grey where it should be,
// eased towards (0.6x to 1.8x, so nothing burns out) from last frame's at `speed` per second (faster into the dark is slower,
// as eyes are). The tonemap multiplies its exposure by it.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint src; // the smallest bloom level
uint prev; // last frame's (0 = start fresh)
float dt;
float speed;
float key; // the brightness to aim for
float minE, maxE;
float pad;
} pc;
layout(location = 0) out vec4 o_exposure;
void main() {
float sum = 0.0;
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x) {
vec3 c = sampleTexLod(pc.src, (vec2(x, y) + 0.5) / 4.0, 0.0).rgb;
sum += log(max(dot(c, vec3(0.2126, 0.7152, 0.0722)), 1e-4));
}
float average = exp(sum / 16.0);
float target = clamp(pc.key / average, pc.minE, pc.maxE);
if (pc.prev == 0u) { o_exposure = vec4(target); return; }
float last = sampleTexLod(pc.prev, vec2(0.5), 0.0).r;
float rate = target > last ? pc.speed * 0.6 : pc.speed; // brightening (into the dark) is slower
o_exposure = vec4(last + (target - last) * (1.0 - exp(-pc.dt * rate)));
}

View file

@ -0,0 +1,61 @@
// The light in the air at a point (volumetric.frag, froxel.comp): the sun through its shadow
// cascades, and the dynamic lights of that point's cluster with a one-tap shadow each. Needs
// FRAME, LIGHT, CELL, LIGHT_INDEX and SHADOW_VIEW (model.glsl with KEEL_FRAME_ONLY).
float sunlitAt(vec3 w) {
float depth = dot(w - FRAME.camPos.xyz, FRAME.camFwd.xyz);
if (depth > FRAME.splits.w) return 1.0;
int c = depth < FRAME.splits.x ? 0 : depth < FRAME.splits.y ? 1 : depth < FRAME.splits.z ? 2 : 3;
vec4 p = FRAME.cascades[c] * vec4(w, 1.0);
vec2 uv = p.xy * 0.5 + 0.5;
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0;
vec2 base = vec2(c & 1, c >> 1) * 0.5;
float t = FRAME.shadow.x;
return shadowTap(uint(FRAME.sunDir.w), base + uv * 0.5, base + t, base + 0.5 - t, p.z - 0.0005);
}
// The light cluster a point in the air is in (render/clusters.h), from its pixel's place on screen.
const uvec3 kClusters = uvec3(16, 9, 24);
const float kClusterNear = 8.0, kClusterFar = 16384.0;
uint clusterAt(vec3 w, vec2 uv) {
float depth = dot(w - FRAME.camPos.xyz, FRAME.camFwd.xyz);
uint z = depth <= kClusterNear ? 0u : min(uint(log(depth / kClusterNear) / log(kClusterFar / kClusterNear) * float(kClusters.z)), kClusters.z - 1u);
uvec2 tile = min(uvec2(uv * vec2(kClusters.xy)), kClusters.xy - 1u);
return (z * kClusters.y + tile.y) * kClusters.x + tile.x;
}
float phaseHG(float mu, float g) { return (1.0 - g * g) / (12.566 * pow(1.0 + g * g - 2.0 * g * mu, 1.5)); }
// The dynamic lights lighting the air at w (towards the eye along -rd).
vec3 lightsAt(vec3 w, vec3 rd, vec2 uv) {
if (FRAME.camPos.w < 0.5) return vec3(0.0);
uvec2 cell = CELL(clusterAt(w, uv));
vec3 total = vec3(0.0);
for (uint k = 0u; k < min(cell.y, 8u); ++k) {
Light l = LIGHT(LIGHT_INDEX(cell.x + k));
vec3 toLight = l.posRadius.xyz - w;
float dist = length(toLight);
if (dist >= l.posRadius.w) continue;
vec3 L = toLight / max(dist, 0.001);
float a = 1.0 - dist / l.posRadius.w, atten = a * a;
if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz));
if (atten <= 0.0) continue;
if (l.color.w >= 0.0) { // shadowed: one tap in its atlas tile
int view = int(l.color.w);
if (l.spot.w <= -1.5) {
vec3 d = -toLight, ad = abs(d);
view += ad.x >= ad.y && ad.x >= ad.z ? (d.x > 0.0 ? 0 : 1) : ad.y >= ad.z ? (d.y > 0.0 ? 2 : 3) : (d.z > 0.0 ? 4 : 5);
}
ShadowView sv = SHADOW_VIEW(view);
vec4 c = sv.viewProj * vec4(w, 1.0);
if (c.w > 0.0) {
c.xyz /= c.w;
if (all(lessThanEqual(abs(c.xy), vec2(1.0))) && c.z <= 1.0) {
float texel = 1.0 / FRAME.screen.w;
vec2 at = sv.rect.xy + (c.xy * 0.5 + 0.5) * sv.rect.zw;
atten *= shadowTap(uint(FRAME.screen.z), at, sv.rect.xy + texel, sv.rect.xy + sv.rect.zw - texel, c.z - 0.00005);
}
}
}
total += l.color.rgb * atten * phaseHG(dot(rd, -L), 0.3);
}
return total;
}

View file

@ -0,0 +1,16 @@
#ifndef KEEL_BACKEND_GLSL
#define KEEL_BACKEND_GLSL
// What every shader includes: textures and the push block, for the backend being built.
// Vulkan: bindless descriptor array + push constants. OpenGL 4.6: ARB_bindless_texture handles
// in storage buffers + a uniform block. Shader code only uses the names defined here.
#ifdef KEEL_GL
#include "backend_gl.glsl"
#else
#include "backend_vk.glsl"
#endif
float sunLight(vec3 n) {
const vec3 sun = normalize(vec3(0.4, 0.3, 0.85));
return 0.3 + 0.9 * max(dot(n, sun), 0.0);
}
#endif

View file

@ -0,0 +1,26 @@
// OpenGL 4.6 + ARB_bindless_texture (enabled by the loader's preamble): every texture is a
// 64-bit handle (sampler state included) in a table, indexed like Vulkan's descriptor array.
// Depth textures also get a comparison handle in a second table, for shadows.
layout(std430, binding = 9) readonly buffer TextureHandles { uvec2 handles[]; } u_textureHandles;
layout(std430, binding = 10) readonly buffer ShadowHandles { uvec2 handles[]; } u_shadowHandles;
#define PUSH_BLOCK layout(std140, binding = 0) uniform Push
#define VERTEX_ID gl_VertexID
vec4 sampleTex(uint index, vec2 uv) {
return texture(sampler2D(u_textureHandles.handles[index]), uv);
}
ivec2 texSize(uint index) { return textureSize(sampler2D(u_textureHandles.handles[index]), 0); }
vec4 fetchTex(uint index, ivec2 p) {
return texelFetch(sampler2D(u_textureHandles.handles[index]), p, 0);
}
vec4 sampleTexLod(uint index, vec2 uv, float lod) {
return textureLod(sampler2D(u_textureHandles.handles[index]), uv, lod);
}
float shadowTap(uint index, vec2 uv, vec2 lo, vec2 hi, float ref) {
return textureLod(sampler2DShadow(u_shadowHandles.handles[index]), vec3(clamp(uv, lo, hi), ref), 0.0);
}

View file

@ -0,0 +1,35 @@
// Vulkan: one global descriptor array of textures, separate samplers, push constants.
#extension GL_EXT_nonuniform_qualifier : require
#extension GL_EXT_buffer_reference : require
layout(set = 0, binding = 0) uniform texture2D u_textures[];
layout(set = 0, binding = 1) uniform sampler u_sampler;
layout(set = 0, binding = 2) uniform sampler u_shadowSampler; // depth compare, linear: bilinear PCF
layout(set = 0, binding = 3) uniform sampler u_clampSampler; // render targets, for the screen-space passes
// Post passes (#define KEEL_POST before including): every lookup clamps at the edges.
#ifdef KEEL_POST
#define KEEL_SAMPLER u_clampSampler
#else
#define KEEL_SAMPLER u_sampler
#endif
#define PUSH_BLOCK layout(push_constant) uniform Push
#define VERTEX_ID gl_VertexIndex
vec4 sampleTex(uint index, vec2 uv) {
return texture(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), uv);
}
ivec2 texSize(uint index) { return textureSize(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), 0); }
vec4 fetchTex(uint index, ivec2 p) {
return texelFetch(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), p, 0);
}
vec4 sampleTexLod(uint index, vec2 uv, float lod) {
return textureLod(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), uv, lod);
}
// 1 = lit. Hardware-filtered depth compare at uv (clamped inside [lo, hi]).
float shadowTap(uint index, vec2 uv, vec2 lo, vec2 hi, float ref) {
return textureLod(sampler2DShadow(u_textures[nonuniformEXT(index)], u_shadowSampler), vec3(clamp(uv, lo, hi), ref), 0.0);
}

View file

@ -0,0 +1,30 @@
#version 460
// Bloom, going down: a 13-tap filter (Jimenez, "Next generation post processing in Call of
// Duty: Advanced Warfare") halving the size each step. The first step clamps fireflies.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint src;
uint srcW, srcH;
uint outW, outH;
uint first;
} pc;
layout(location = 0) out vec4 o_color;
vec3 tap(vec2 uv) {
vec2 h = 0.5 / vec2(pc.srcW, pc.srcH);
return sampleTex(pc.src, clamp(uv, h, 1.0 - h)).rgb;
}
void main() {
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
vec2 t = 1.0 / vec2(pc.srcW, pc.srcH);
vec3 a = tap(uv + t * vec2(-2, -2)), b = tap(uv + t * vec2(0, -2)), c = tap(uv + t * vec2(2, -2));
vec3 d = tap(uv + t * vec2(-1, -1)), e = tap(uv + t * vec2(1, -1));
vec3 f = tap(uv + t * vec2(-2, 0)), g = tap(uv), hh = tap(uv + t * vec2(2, 0));
vec3 i = tap(uv + t * vec2(-1, 1)), j = tap(uv + t * vec2(1, 1));
vec3 k = tap(uv + t * vec2(-2, 2)), l = tap(uv + t * vec2(0, 2)), m = tap(uv + t * vec2(2, 2));
vec3 sum = (d + e + i + j) * 0.125 + (a + c + k + m) * 0.03125 + (b + f + hh + l) * 0.0625 + g * 0.125;
if (pc.first != 0u) sum = min(sum, vec3(32.0)); // no single-pixel sparkle blooming the screen
o_color = vec4(sum, 1.0);
}

View file

@ -0,0 +1,25 @@
#version 460
// Bloom, coming up: a 3×3 tent of the smaller level, added onto the larger one (blended).
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint src;
uint srcW, srcH;
uint outW, outH;
float radius;
} pc;
layout(location = 0) out vec4 o_color;
vec3 tap(vec2 uv) {
vec2 h = 0.5 / vec2(pc.srcW, pc.srcH);
return sampleTex(pc.src, clamp(uv, h, 1.0 - h)).rgb;
}
void main() {
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
vec2 t = pc.radius / vec2(pc.srcW, pc.srcH);
vec3 s = tap(uv) * 4.0;
s += (tap(uv + vec2(-t.x, 0)) + tap(uv + vec2(t.x, 0)) + tap(uv + vec2(0, -t.y)) + tap(uv + vec2(0, t.y))) * 2.0;
s += tap(uv + vec2(-t.x, -t.y)) + tap(uv + vec2(t.x, -t.y)) + tap(uv + vec2(-t.x, t.y)) + tap(uv + vec2(t.x, t.y));
o_color = vec4(s / 16.0, 1.0);
}

20
engine/shaders/box.frag Normal file
View file

@ -0,0 +1,20 @@
#version 460
#include "backend.glsl"
PUSH_BLOCK {
mat4 viewProj;
vec4 center;
vec4 extents;
vec4 color;
} pc;
layout(location = 0) in vec3 v_normal;
layout(location = 1) in vec3 v_world;
layout(location = 0) out vec4 o_color;
void main() {
// World-space UVs: textures tile every 128 units regardless of box size.
vec3 n = abs(v_normal);
vec2 uv = (n.x > 0.5 ? v_world.yz : n.y > 0.5 ? v_world.xz : v_world.xy) / 128.0;
vec4 tex = sampleTex(uint(pc.center.w), uv);
o_color = vec4(pc.color.rgb * tex.rgb * sunLight(v_normal), 1.0);
}

26
engine/shaders/box.vert Normal file
View file

@ -0,0 +1,26 @@
#version 460
// Axis-aligned boxes generated from the vertex index: 36 vertices, no vertex buffer.
#include "backend.glsl"
PUSH_BLOCK {
mat4 viewProj;
vec4 center; // xyz, w = texture index
vec4 extents; // xyz = half size
vec4 color;
} pc;
layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec3 v_world;
const vec3 kNormals[6] = vec3[](vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0), vec3(0,0,1), vec3(0,0,-1));
const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1));
void main() {
int face = VERTEX_ID / 6;
vec3 n = kNormals[face];
vec3 t = abs(n.z) > 0.5 ? vec3(1,0,0) : vec3(0,0,1);
vec3 b = cross(n, t);
vec2 q = kQuad[VERTEX_ID % 6];
v_world = pc.center.xyz + (n + t * q.x + b * q.y) * pc.extents.xyz;
v_normal = n;
gl_Position = pc.viewProj * vec4(v_world, 1.0);
}

59
engine/shaders/cull.comp Normal file
View file

@ -0,0 +1,59 @@
#version 460
// GPU occlusion culling (r_gpuculling): one thread per opaque draw. Its world box, projected the
// way the occlusion grid's frame saw the world (last frame's view), is hidden if every grid texel
// under it holds something nearer than the box's nearest point. Writes the draw's indirect command
// (one instance or none) for the depth pre-pass and the scene pass, and counts the hidden ones.
#include "backend.glsl"
layout(local_size_x = 64) in;
struct CullBox { vec4 lo; vec4 hi; uvec4 draw; }; // draw: index count, first index, test (1), -
layout(buffer_reference, std430) readonly buffer Boxes { CullBox b[]; };
struct Cmd { uint indexCount, instanceCount, firstIndex; int vertexOffset; uint firstInstance; };
layout(buffer_reference, std430) writeonly buffer Cmds { Cmd c[]; };
layout(buffer_reference, std430) buffer Counter { uint hidden; };
PUSH_BLOCK {
mat4 viewProj; // the frame the grid was made from
Boxes boxes;
Cmds cmds;
Counter counter;
uint count;
uint hzb; // the pyramid's first level (kHzbLevels slots, each half the last)
uint occlusion; // 0: the camera moved too much since (draw everything in view)
uint pad;
} pc;
void main() {
uint i = gl_GlobalInvocationID.x;
if (i >= pc.count) return;
CullBox b = pc.boxes.b[i];
bool visible = true;
if (pc.occlusion != 0u && (b.draw.z & 1u) != 0u) {
vec3 mn = vec3(1e9), mx = vec3(-1e9);
bool behind = false;
for (int k = 0; k < 8; ++k) {
vec3 c = vec3((k & 1) != 0 ? b.hi.x : b.lo.x, (k & 2) != 0 ? b.hi.y : b.lo.y, (k & 4) != 0 ? b.hi.z : b.lo.z);
vec4 p = pc.viewProj * vec4(c, 1.0);
if (p.w <= 0.001) { behind = true; break; } // crosses the eye: can't tell, draw it
vec3 n = p.xyz / p.w;
mn = min(mn, n), mx = max(mx, n);
}
if (!behind) {
// The level where the box covers a few texels (each holds the farthest depth under
// it, so testing there never hides what's visible).
vec2 size0 = vec2(texSize(pc.hzb));
vec2 extent = (mx.xy - mn.xy) * 0.5 * size0;
uint level = uint(clamp(ceil(log2(max(max(extent.x, extent.y), 1.0) / 3.0)), 0.0, 7.0));
uint tex = pc.hzb + level;
ivec2 size = texSize(tex);
ivec2 a = clamp(ivec2(floor((mn.xy * 0.5 + 0.5) * vec2(size))), ivec2(0), size - 1);
ivec2 z = clamp(ivec2(floor((mx.xy * 0.5 + 0.5) * vec2(size))), ivec2(0), size - 1);
float far = 0.0;
for (int y = a.y; y <= z.y; ++y)
for (int x = a.x; x <= z.x; ++x) far = max(far, fetchTex(tex, ivec2(x, y)).r);
if (mn.z > far + 1e-5) visible = false;
}
}
pc.cmds.c[i] = Cmd(b.draw.x, visible ? 1u : 0u, b.draw.y, 0, 0u);
if (!visible) atomicAdd(pc.counter.hidden, 1u);
}

11
engine/shaders/depth.frag Normal file
View file

@ -0,0 +1,11 @@
#version 460
// Depth pre-pass (r_prepass): only the depth, with alpha-tested texels cut out like mesh.frag
// does, so the scene pass shades each pixel once (what's hidden behind isn't lit for nothing).
#include "backend.glsl"
#include "model.glsl"
layout(location = 1) in vec2 v_uv;
void main() {
if (sampleTex(pc.textures & 0xFFFFu, v_uv).a < 0.5) discard;
}

View file

@ -0,0 +1,2 @@
#version 460
#include "froxel_main.glsl"

View file

@ -0,0 +1,30 @@
// The fog grid ("froxels", as in Frostbite / id Tech): kFroxels cells over the screen and along
// the view, slices spaced exponentially from FRAME.froxel.y to .z. froxel.comp fills it; entry k
// of a column holds the light the fog adds (rgb) and how much of what's behind gets through (a)
// from the eye to the far side of slice k.
const uvec3 kFroxels = uvec3(160, 90, 64);
uint froxelIndex(uvec3 c) { return (c.z * kFroxels.y + c.y) * kFroxels.x + c.x; }
// Where slice boundary j lies (view depth); slice k spans boundaries k and k + 1.
float froxelBoundary(float j) { return FRAME.froxel.y * pow(FRAME.froxel.z / FRAME.froxel.y, j / float(kFroxels.z)); }
float froxelSlice(float depth) { return log(max(depth, 1e-3) / FRAME.froxel.y) / log(FRAME.froxel.z / FRAME.froxel.y) * float(kFroxels.z); }
#ifndef KEEL_FROXEL_WRITER
vec4 froxelEntry(ivec2 xy, int z) {
uvec2 v = FRAME.froxels.f[froxelIndex(uvec3(clamp(xy, ivec2(0), ivec2(kFroxels.xy) - 1), clamp(z, 0, int(kFroxels.z) - 1)))];
return vec4(unpackHalf2x16(v.x), unpackHalf2x16(v.y));
}
vec4 froxelColumn(ivec2 xy, float c) { // c: entry index, fractional
if (c < 0.0) return mix(vec4(0.0, 0.0, 0.0, 1.0), froxelEntry(xy, 0), clamp(c + 1.0, 0.0, 1.0));
int z = int(c);
return mix(froxelEntry(xy, z), froxelEntry(xy, z + 1), c >= float(kFroxels.z - 1) ? 0.0 : fract(c));
}
// The fog between the eye and a point at uv on screen, depth along the view: (light, transmittance).
vec4 froxelFog(vec2 uv, float depth) {
vec2 g = uv * vec2(kFroxels.xy) - 0.5;
ivec2 b = ivec2(floor(g));
vec2 f = g - vec2(b);
// Entry k ends at boundary k + 1; nearer than the first slice's far side fades in from nothing.
float c = max(froxelSlice(depth) - 1.0, -1.0);
return mix(mix(froxelColumn(b, c), froxelColumn(b + ivec2(1, 0), c), f.x), mix(froxelColumn(b + ivec2(0, 1), c), froxelColumn(b + ivec2(1, 1), c), f.x), f.y);
}
#endif

View file

@ -0,0 +1,218 @@
// The body of froxel.comp and froxel_rt.comp (the same, plus rays for the sky above each cell).
// The fog grid (froxel.glsl), in two dispatches:
// mode 0, one thread per cell: the fog's density there (height fog, plus the air when
// r_volumetric's on) and the light it scatters towards the eye (the sky's, the sun through its
// shadow, the lamps of its cluster), at a point jittered along the slice each frame and blended
// with last frame's cell at the same place in the world, so it's smooth and doesn't crawl.
// mode 1, one thread per column: walked front to back, adding up light and transmittance.
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
#include "sky.glsl"
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(buffer_reference, std430) buffer Cells4 { vec4 c[]; }; // per cell: scattered light (per unit), extinction
layout(buffer_reference, std430) writeonly buffer Out { uvec2 f[]; };
layout(buffer_reference, std430) readonly buffer SmokeCells { uint v[]; }; // two cells a word
layout(buffer_reference, std430) writeonly buffer SmokeSky { uvec2 f[]; }; // a column's smoke alone
PUSH_BLOCK {
FrameData frame;
Cells4 cur, prev;
Out integrated;
vec4 proj; // tan(half fov x), tan(half fov y)
vec4 prevPos; // last frame's eye; w 1 = there's a last frame to blend with
vec4 prevFwd; // and its view direction; w this frame's jitter along the slice (0..1)
uint mode;
uint frameIndex;
uvec2 tlas; // KEEL_FOG_RAYS: the scene's acceleration structure
SmokeCells smoke; // the smoke grid (render/backend.h kSmokeDims): density | shade << 8 a cell
SmokeSky smokeSky; // integrate: each column's smoke alone, end to end (smoke_sky.frag lays it over the sky)
vec4 smokeOrigin; // its world corner; w a clock (seconds) for the billows, < 0: no smoke
} pc;
#define FRAME pc.frame
#define KEEL_FROXEL_WRITER
#include "froxel.glsl"
#include "airlight.glsl"
// ── Smoke (shared/smoke.h): the grid's cells, blended, roughened by drifting billows ──
const ivec3 kSmokeDims = ivec3(160, 160, 48);
const float kSmokeCell = 16.0;
vec2 smokeCell(ivec3 c) {
if (any(lessThan(c, ivec3(0))) || any(greaterThanEqual(c, kSmokeDims))) return vec2(0.0);
uint i = uint((c.z * kSmokeDims.y + c.y) * kSmokeDims.x + c.x);
uint h = (pc.smoke.v[i >> 1] >> ((i & 1u) * 16u)) & 0xFFFFu;
return vec2(float(h & 0xFFu), float(h >> 8)) / 255.0;
}
// x density, y shade (both blended between the 8 cells round w).
vec2 smokeAt(vec3 w) {
vec3 g = (w - pc.smokeOrigin.xyz) / kSmokeCell - 0.5;
ivec3 b = ivec3(floor(g));
vec3 f = g - vec3(b);
float d = 0.0, s = 0.0;
for (int k = 0; k < 8; ++k) {
ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2);
vec3 tri = mix(1.0 - f, f, vec3(o));
vec2 c = smokeCell(b + o);
float wgt = tri.x * tri.y * tri.z;
d += c.x * wgt;
s += c.y * c.x * wgt;
}
return vec2(d, d > 1e-4 ? s / d : 0.5);
}
float hash3(vec3 p) {
p = fract(p * 0.3183099 + 0.1) * 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
float valueNoise(vec3 x) {
vec3 i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y),
mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);
}
// The density there after the billows: thick inside, torn and drifting at the edges.
float smokeDensity(vec3 w, float d) {
if (d <= 0.0) return 0.0;
float t = pc.smokeOrigin.w;
vec3 q = w * 0.03 + vec3(t * 0.04, t * 0.03, -t * 0.12);
float n = 0.5 * valueNoise(q) + 0.3 * valueNoise(q * 2.3 + vec3(t * 0.07)) + 0.2 * valueNoise(q * 5.1 - vec3(t * 0.1));
// Thin smoke (the rim) is torn most; the thick middle only darkens and lightens a little.
return clamp(d * 1.6 - (1.0 - n) * mix(1.1, 0.5, d), 0.0, 1.0) * mix(0.75, 1.15, n);
}
vec3 viewRay(vec3 fwd, vec2 uv) { // not normalized: 1 along fwd
vec3 right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec2 s = (uv * 2.0 - 1.0) * pc.proj.xy;
return fwd + right * s.x - up * s.y;
}
// Last frame's cell (blended) at a world point; w < 0 when it was off that grid.
vec4 history(vec3 w) {
vec3 fwd = pc.prevFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec3 v = w - pc.prevPos.xyz;
float depth = dot(v, fwd);
if (depth < FRAME.froxel.y) return vec4(-1.0);
vec2 uv = (vec2(dot(v, right), -dot(v, up)) / depth / pc.proj.xy) * 0.5 + 0.5;
vec3 g = vec3(uv * vec2(kFroxels.xy), froxelSlice(depth)) - 0.5;
if (any(lessThan(g, vec3(0.0))) || any(greaterThan(g, vec3(kFroxels) - 1.0))) return vec4(-1.0);
ivec3 b = ivec3(floor(g));
vec3 f = g - vec3(b);
vec4 r = vec4(0.0);
for (int k = 0; k < 8; ++k) {
ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2);
float wgt = (o.x != 0 ? f.x : 1.0 - f.x) * (o.y != 0 ? f.y : 1.0 - f.y) * (o.z != 0 ? f.z : 1.0 - f.z);
r += pc.prev.c[froxelIndex(uvec3(min(b + o, ivec3(kFroxels) - 1)))] * wgt;
}
return r;
}
void inject(uvec3 c) {
vec2 uv = (vec2(c.xy) + 0.5) / vec2(kFroxels.xy);
vec3 ray = viewRay(FRAME.camFwd.xyz, uv), rd = normalize(ray);
vec3 w = FRAME.camPos.xyz + ray * froxelBoundary(float(c.z) + pc.prevFwd.w);
float fog = 0.0;
if (FRAME.sky.x > 0.5 && FRAME.fog.x > 0.0) fog = FRAME.fog.x * exp(-FRAME.fog.y * (w.z - FRAME.fog.w));
float air = FRAME.volumetric.x > 0.0 ? FRAME.volumetric.z : 0.0;
vec2 smoke = pc.smokeOrigin.w >= 0.0 ? smokeAt(w) : vec2(0.0);
float smokeExt = smokeDensity(w, smoke.x) * 0.035; // (thick smoke: ~30 units hide what's behind)
vec3 light = vec3(0.0);
if (fog + air + smokeExt > 0.0) {
const bool sunOn = FRAME.sunColor.w > 0.5;
float lit = sunOn ? sunlitAt(w) : 0.0;
float mu = dot(rd, FRAME.sunDir.xyz);
vec3 lamps = lightsAt(w, rd, uv);
// The fog: lit by the sky (blurred, as the old height fog was) and, where the sun reaches
// it, glowing towards the sun; the lamps light it too.
vec3 sky = FRAME.sky.x > 0.5 ? skyRadiance(uint(FRAME.sky.x), FRAME.sky.y, FRAME.sky.w, normalize(vec3(rd.xy, max(rd.z, 0.05))), FRAME.sky.z) * 0.9 : vec3(0.0);
vec3 smokeSky = sky; // (the smoke's own: steady, no per-frame noise to average away)
#ifdef KEEL_FOG_RAYS
// How much sky this point sees: one ray a frame into the sky above it, in a different
// direction each frame (the blend with last frame's cells averages them). Under a roof
// the fog gets next to no sky light, so rooms stay clear instead of filling with grey.
uint h = (c.x * 73856093u) ^ (c.y * 19349663u) ^ (c.z * 83492791u) ^ (pc.frameIndex * 2654435761u);
h = h * 747796405u + 2891336453u;
float r1 = float(h & 0xFFFFu) / 65535.0, r2 = float(h >> 16) / 65535.0;
vec3 up = normalize(vec3(cos(r1 * 6.2831853), sin(r1 * 6.2831853), 0.0) * sqrt(r2) * 1.2 + vec3(0.0, 0.0, 1.0));
rayQueryEXT q;
rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, w, 1.0, up, 6000.0);
while (rayQueryProceedEXT(q)) {}
float open = rayQueryGetIntersectionTypeEXT(q, true) == gl_RayQueryCommittedIntersectionNoneEXT ? 1.0 : 0.0;
sky *= mix(0.06, 1.0, open);
if (smokeExt > 0.0) { // under a roof or not: one ray straight up
rayQueryEXT q2;
rayQueryInitializeEXT(q2, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, w, 1.0, vec3(0.0, 0.0, 1.0), 6000.0);
while (rayQueryProceedEXT(q2)) {}
smokeSky *= rayQueryGetIntersectionTypeEXT(q2, true) == gl_RayQueryCommittedIntersectionNoneEXT ? 1.0 : 0.3;
}
#else
// Where the sun can't reach (under a roof, mostly) the sky's light is dimmer too.
if (sunOn) sky *= mix(0.3, 1.0, lit);
smokeSky = sky;
#endif
vec3 fogLight = sky + (sunOn ? FRAME.sunColor.rgb * lit * pow(max(mu, 0.0), 8.0) * 0.25 : vec3(0.0)) + lamps * 12.0;
// The air (r_volumetric): shafts of sun and haloes round lamps; it adds light only.
vec3 airLight = (sunOn && FRAME.volumetric.w > 0.5 ? FRAME.sunColor.rgb * lit * phaseHG(mu, 0.6) : vec3(0.0)) + lamps * 7.0;
light = fog * fogLight + air * airLight * FRAME.volumetric.x;
if (smokeExt > 0.0) {
// Smoke: the sky's light all round, the sun through its shadows and through the smoke
// between it and the sun (a few steps that way), the lamps; tinted by its shade.
float toward = sunOn ? smokeAt(w + FRAME.sunDir.xyz * 24.0).x + smokeAt(w + FRAME.sunDir.xyz * 64.0).x + smokeAt(w + FRAME.sunDir.xyz * 128.0).x : 0.0;
vec3 sunIn = sunOn ? FRAME.sunColor.rgb * lit * exp(-toward * 1.4) * 0.5 : vec3(0.0);
// The smoke above shades what's under it (the sky's light comes mostly from above).
float above = smokeAt(w + vec3(0.0, 0.0, 32.0)).x + smokeAt(w + vec3(0.0, 0.0, 80.0)).x;
light += smokeExt * smoke.y * (smokeSky * mix(1.0, 0.35, clamp(above * 0.5, 0.0, 1.0)) + sunIn + lamps * 4.0);
}
}
vec4 now = vec4(light, fog + smokeExt);
uint i = froxelIndex(c);
if (pc.prevPos.w > 0.5) {
vec4 h = history(w);
if (h.w >= 0.0) now = mix(h, now, 0.12);
}
pc.cur.c[i] = now;
}
void integrate(uvec2 xy) {
vec2 uv = (vec2(xy) + 0.5) / vec2(kFroxels.xy);
float rayScale = length(viewRay(FRAME.camFwd.xyz, uv)); // ray length per unit of depth
vec3 ray = viewRay(FRAME.camFwd.xyz, uv);
vec3 light = vec3(0.0);
// The fog never hides more than its max (FRAME.fog.z; 0 or 1: no cap): once the fog alone has
// hidden that much, the cells further on keep only their smoke (and its share of the light).
float through = 1.0, fogThrough = 1.0, keep = 1.0 - FRAME.fog.z;
vec3 smokeLight = vec3(0.0);
float smokeThrough = 1.0;
bool capped = FRAME.fog.z > 0.0 && FRAME.fog.z < 1.0;
float near = 0.0;
for (uint z = 0u; z < kFroxels.z; ++z) {
float far = froxelBoundary(float(z + 1u)), len = (far - near) * rayScale;
vec3 w = FRAME.camPos.xyz + ray * (near + far) * 0.5;
near = far;
vec4 cell = pc.cur.c[froxelIndex(uvec3(xy, z))];
// The smoke here, sampled again (exact: it doesn't move with the slices' jitter as the fog
// would); the rest of the cell's extinction is the fog.
float smokeHere = pc.smokeOrigin.w >= 0.0 ? min(smokeDensity(w, smokeAt(w).x) * 0.035, cell.w) : 0.0;
float fog = max(cell.w - smokeHere, 0.0);
if (capped && fogThrough <= keep && cell.w > 1e-9) {
cell.rgb *= (cell.w - fog) / cell.w;
cell.w -= fog;
fog = 0.0;
}
fogThrough *= exp(-fog * len);
if (smokeHere > 1e-9) { // the smoke's share of the cell
float se = smokeHere, st = exp(-se * len);
smokeLight += smokeThrough * cell.rgb * (se / cell.w) * (1.0 - st) / se;
smokeThrough *= st;
}
float t = exp(-cell.w * len);
light += through * (cell.w > 1e-9 ? cell.rgb * (1.0 - t) / cell.w : cell.rgb * len);
through *= t;
pc.integrated.f[froxelIndex(uvec3(xy, z))] = uvec2(packHalf2x16(light.rg), packHalf2x16(vec2(light.b, through)));
}
if (pc.smokeOrigin.w >= 0.0) pc.smokeSky.f[xy.y * kFroxels.x + xy.x] = uvec2(packHalf2x16(smokeLight.rg), packHalf2x16(vec2(smokeLight.b, smokeThrough)));
}
void main() {
uvec3 id = gl_GlobalInvocationID;
if (any(greaterThanEqual(id.xy, kFroxels.xy))) return;
if (pc.mode == 0u) inject(id);
else integrate(id.xy);
}

View file

@ -0,0 +1,5 @@
#version 460
// The fog grid with ray queries: each cell sees whether it has sky above it (froxel_main.glsl).
#extension GL_EXT_ray_query : require
#define KEEL_FOG_RAYS
#include "froxel_main.glsl"

View file

@ -0,0 +1,11 @@
#version 460
#include "backend.glsl"
layout(location = 0) out vec2 v_uv;
void main() {
v_uv = vec2((VERTEX_ID << 1) & 2, VERTEX_ID & 2);
gl_Position = vec4(v_uv * 2.0 - 1.0, 0.0, 1.0);
#ifdef KEEL_GL
// The window shows row 0 at the bottom in OpenGL; our images keep the view's top in row 0 (as in Vulkan).
v_uv.y = 1.0 - v_uv.y;
#endif
}

143
engine/shaders/gi.comp Normal file
View file

@ -0,0 +1,143 @@
#version 460
// Real-time GI (gi.glsl): one workgroup per probe, one hardware ray per thread (ray queries
// against this frame's scene). Where a ray lands: that surface's colour, lit by the dynamic
// lights that reach it (a shadow ray each) and by last frame's probes there (so light bounces
// more than once); behind a surface (the probe's stuck in a wall) it counts against the probe.
// Then the probe's harmonics and distances, blended with what it had.
#extension GL_EXT_ray_query : require
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
layout(local_size_x = 32) in;
layout(buffer_reference, std430) readonly buffer GiVertices { Vertex v[]; };
layout(buffer_reference, std430) readonly buffer GiIndices { uint i[]; };
struct RayInst { GiVertices vertices; GiIndices indices; uint firstIndex, textures, tint, pad; };
layout(buffer_reference, std430) readonly buffer RayInsts { RayInst r[]; };
layout(buffer_reference, std430) writeonly buffer GiProbesOut { GiProbe p[]; };
PUSH_BLOCK {
FrameData frame;
GiProbesOut cur;
GiProbes prev;
RayInsts insts;
uvec2 tlas; // the scene's acceleration structure
uint fresh; // 1: start over (nothing to blend with)
float hysteresis; // how much of the old light stays each frame
vec4 rotation; // this frame's turn of the ray pattern (quaternion)
} pc;
#define FRAME pc.frame
#define GI_PROBES pc.prev
#include "gi.glsl"
const uint kRays = 32u;
shared vec3 s_light[kRays], s_dir[kRays];
shared float s_dist[kRays];
shared uint s_back[kRays];
vec3 rotate(vec4 q, vec3 v) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); }
vec3 fibonacci(uint i, uint n) {
float z = 1.0 - (2.0 * float(i) + 1.0) / float(n), r = sqrt(max(1.0 - z * z, 0.0));
float phi = float(i) * 2.39996323;
return vec3(cos(phi) * r, sin(phi) * r, z);
}
bool blocked(vec3 from, vec3 dir, float len) {
rayQueryEXT q;
rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, from, 0.01, dir, len);
while (rayQueryProceedEXT(q)) {}
return rayQueryGetIntersectionTypeEXT(q, true) != gl_RayQueryCommittedIntersectionNoneEXT;
}
void main() {
uint probe = gl_WorkGroupID.x, t = gl_LocalInvocationID.x;
ivec3 slot = ivec3(probe % uint(kGiDims.x), (probe / uint(kGiDims.x)) % uint(kGiDims.y), probe / uint(kGiDims.x * kGiDims.y));
ivec3 origin = ivec3(FRAME.giOrigin.xyz);
ivec3 cell = origin + giWrap(slot - origin); // the world cell this slot holds now
vec3 pos = (vec3(cell) + 0.5) * FRAME.gi.y; // between floors and walls (on the grid lines maps are built on)
GiProbe old = pc.prev.p[probe];
bool fresh = pc.fresh != 0u || old.tag.w < 0.5 || ivec3(old.tag.xyz) != cell;
vec3 dir = normalize(rotate(pc.rotation, fibonacci(t, kRays)));
const float kFar = FRAME.gi.y * 8.0;
rayQueryEXT q;
rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsOpaqueEXT, 0xFF, pos, 0.0, dir, kFar);
while (rayQueryProceedEXT(q)) {}
vec3 light = vec3(0.0);
float dist = kFar;
uint back = 0u;
if (rayQueryGetIntersectionTypeEXT(q, true) == gl_RayQueryCommittedIntersectionTriangleEXT) {
dist = rayQueryGetIntersectionTEXT(q, true);
RayInst ri = pc.insts.r[rayQueryGetIntersectionInstanceCustomIndexEXT(q, true)];
uint first = ri.firstIndex + uint(rayQueryGetIntersectionPrimitiveIndexEXT(q, true)) * 3u;
Vertex a = ri.vertices.v[ri.indices.i[first]], b = ri.vertices.v[ri.indices.i[first + 1u]], c = ri.vertices.v[ri.indices.i[first + 2u]];
vec2 bc = rayQueryGetIntersectionBarycentricsEXT(q, true);
vec3 w3 = vec3(1.0 - bc.x - bc.y, bc.x, bc.y);
mat4x3 toWorld = rayQueryGetIntersectionObjectToWorldEXT(q, true);
vec3 n = normalize(mat3(toWorld) * (vec3(a.nx, a.ny, a.nz) * w3.x + vec3(b.nx, b.ny, b.nz) * w3.y + vec3(c.nx, c.ny, c.nz) * w3.z));
if (dot(n, dir) > 0.0) { // its back: inside something (a wall's far side, a closed box)
back = 1u;
dist *= 0.2;
} else {
vec2 uv = vec2(a.u, a.v) * w3.x + vec2(b.u, b.v) * w3.y + vec2(c.u, c.v) * w3.z;
vec3 albedo = sampleTexLod(ri.textures & 0xFFFFu, uv, 4.0).rgb * unpackUnorm4x8(ri.tint).rgb;
vec3 hit = pos + dir * dist + n * 0.5;
vec3 direct = vec3(0.0);
uint count = min(uint(FRAME.camPos.w), 32u);
for (uint k = 0u; k < count; ++k) {
Light l = LIGHT(k);
vec3 toLight = l.posRadius.xyz - hit;
float d = length(toLight);
if (d >= l.posRadius.w) continue;
vec3 L = toLight / max(d, 0.001);
float ndl = dot(n, L);
if (ndl <= 0.0) continue;
float fall = 1.0 - d / l.posRadius.w, atten = fall * fall;
if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz));
// (Stopping short of the light: lamps sit inside their fittings.)
if (atten <= 1e-3 || blocked(hit, L, max(d - 12.0, 0.0))) continue;
direct += l.color.rgb * atten * ndl;
}
// More bounces: what last frame's probes say reaches that spot.
direct += giIrradiance(hit, n, -dir);
light = albedo * direct;
}
}
s_light[t] = light;
s_dir[t] = dir;
s_dist[t] = dist;
s_back[t] = back;
barrier();
if (t != 0u) return;
GiProbe p;
for (int i = 0; i < 4; ++i) p.sh[i] = vec4(0.0);
vec4 mean0 = vec4(0.0), mean1 = vec4(0.0), w0 = vec4(0.0), w1 = vec4(0.0); // +x -x +y -y, +z -z (and their squares)
vec4 sq0 = vec4(0.0), sq1 = vec4(0.0);
uint backs = 0u;
const float weight = 12.566371 / float(kRays);
for (uint i = 0u; i < kRays; ++i) {
vec3 d = s_dir[i], L = s_light[i] * weight;
p.sh[0].rgb += L * 0.282095;
p.sh[1].rgb += L * 0.488603 * d.y;
p.sh[2].rgb += L * 0.488603 * d.z;
p.sh[3].rgb += L * 0.488603 * d.x;
vec4 a = vec4(max(d.x, 0.0), max(-d.x, 0.0), max(d.y, 0.0), max(-d.y, 0.0)), b = vec4(max(d.z, 0.0), max(-d.z, 0.0), 0.0, 0.0);
a = pow(a, vec4(4.0)), b = pow(b, vec4(4.0));
float r = s_dist[i];
mean0 += a * r, sq0 += a * r * r, w0 += a;
mean1 += b * r, sq1 += b * r * r, w1 += b;
backs += s_back[i];
}
mean0 /= max(w0, vec4(1e-4)), sq0 /= max(w0, vec4(1e-4));
mean1 /= max(w1, vec4(1e-4)), sq1 /= max(w1, vec4(1e-4));
p.dist[0] = mean0; // +x -x +y -y
p.dist[1] = vec4(mean1.xy, sq0.xy); // +z -z, then squares +x -x
p.dist[2] = vec4(sq0.zw, sq1.xy); // squares +y -y +z -z
// Stuck in a wall (many rays see backs): don't trust it.
p.sh[0].w = float(backs) > float(kRays) * 0.25 ? 0.0 : 1.0;
p.tag = vec4(vec3(cell), 1.0);
if (!fresh) {
float h = pc.hysteresis;
for (int i = 0; i < 4; ++i) p.sh[i].rgb = mix(p.sh[i].rgb, old.sh[i].rgb, h);
for (int i = 0; i < 3; ++i) p.dist[i] = mix(p.dist[i], old.dist[i], 0.97); // shapes change slowly
}
pc.cur.p[probe] = p;
}

56
engine/shaders/gi.glsl Normal file
View file

@ -0,0 +1,56 @@
// Real-time GI probes (gi.comp fills them, with ray queries): a grid of kGiDims probes,
// FRAME.gi.y apart, that follows the eye; a probe's slot is its world cell wrapped into the grid,
// so moving keeps what's already there. They hold the light that dynamic lights (flashlight,
// fire, explosions, lamps) bounce off what's around; the lightmaps already bounce the rest.
// GI_PROBES: the probes to read (#define before including).
const ivec3 kGiDims = ivec3(24, 24, 12);
// a mod kGiDims, also below zero (GLSL's % isn't defined there)
ivec3 giWrap(ivec3 a) { return a - kGiDims * ivec3(floor(vec3(a) / vec3(kGiDims))); }
uint giSlot(ivec3 cell) {
ivec3 s = giWrap(cell);
return uint((s.z * kGiDims.y + s.y) * kGiDims.x + s.x);
}
// Irradiance / π from L1 spherical harmonics of radiance, facing n.
vec3 giShIrradiance(GiProbe p, vec3 n) {
return max(0.282095 * p.sh[0].rgb + 0.325735 * (p.sh[1].rgb * n.y + p.sh[2].rgb * n.z + p.sh[3].rgb * n.x), vec3(0.0));
}
// The light bounced onto a surface at world (normal n, towards the eye v): the 8 probes round it,
// weighted by nearness, whether they're in front of the surface, and whether they can see it
// (what's between, by the distances each one keeps: Chebyshev, as in DDGI).
vec3 giIrradiance(vec3 world, vec3 n, vec3 v) {
float s = FRAME.gi.y;
ivec3 origin = ivec3(FRAME.giOrigin.xyz);
vec3 p = world + (n * 0.2 + v * 0.1) * s;
vec3 g = p / s - 0.5; // probe c sits at (c + 0.5) * s
ivec3 base = ivec3(floor(g));
vec3 f = g - vec3(base);
vec3 sum = vec3(0.0);
float total = 0.0;
for (int k = 0; k < 8; ++k) {
ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2), c = base + o;
if (any(lessThan(c, origin)) || any(greaterThanEqual(c, origin + kGiDims))) continue;
GiProbe pr = GI_PROBES.p[giSlot(c)];
if (pr.tag.w < 0.5 || ivec3(pr.tag.xyz) != c) continue;
vec3 tri = mix(1.0 - f, f, vec3(o));
float w = tri.x * tri.y * tri.z * pr.sh[0].w;
vec3 at = (vec3(c) + 0.5) * s;
vec3 toProbe = normalize(at - world);
float wrap = (dot(toProbe, n) + 1.0) * 0.5;
w *= wrap * wrap + 0.2;
vec3 d = p - at;
float r = length(d);
d /= max(r, 1e-4);
vec3 d2 = d * d;
vec2 m = vec2(d.x > 0.0 ? pr.dist[0].x : pr.dist[0].y, d.x > 0.0 ? pr.dist[1].z : pr.dist[1].w) * d2.x
+ vec2(d.y > 0.0 ? pr.dist[0].z : pr.dist[0].w, d.y > 0.0 ? pr.dist[2].x : pr.dist[2].y) * d2.y
+ vec2(d.z > 0.0 ? pr.dist[1].x : pr.dist[1].y, d.z > 0.0 ? pr.dist[2].z : pr.dist[2].w) * d2.z;
if (r > m.x) {
float variance = abs(m.y - m.x * m.x), e = r - m.x;
float cheb = variance / (variance + e * e);
w *= max(cheb * cheb * cheb, 0.02);
}
sum += giShIrradiance(pr, n) * w;
total += w;
}
return total > 1e-4 ? sum / total : vec3(0.0);
}

24
engine/shaders/hzb.frag Normal file
View file

@ -0,0 +1,24 @@
#version 460
// The hierarchical Z-buffer's base: the scene's depth shrunk to a small grid, each texel the
// farthest depth under it. The CPU reads it back and builds the rest of the pyramid (see
// Renderer::occluded). Rows match the depth buffer's on both backends.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint depth; // texture slot of the scene's depth
uint srcW, srcH; // its size
uint dstW, dstH; // this grid's
} pc;
layout(location = 0) out vec4 o_depth;
void main() {
ivec2 o = ivec2(gl_FragCoord.xy);
vec2 scale = vec2(pc.srcW, pc.srcH) / vec2(pc.dstW, pc.dstH);
ivec2 lo = ivec2(floor(vec2(o) * scale));
ivec2 hi = min(ivec2(ceil(vec2(o + 1) * scale)), ivec2(pc.srcW, pc.srcH));
float far = 0.0;
for (int y = lo.y; y < hi.y; ++y)
for (int x = lo.x; x < hi.x; ++x) far = max(far, fetchTex(pc.depth, ivec2(x, y)).r);
o_depth = vec4(far);
}

View file

@ -0,0 +1,99 @@
// Clustered dynamic lights for mesh.frag. Grid constants match render/clusters.h.
const uvec3 kClusters = uvec3(16, 9, 24);
const float kClusterNear = 8.0, kClusterFar = 16384.0;
uint clusterIndex(vec3 world) {
float depth = dot(world - FRAME.camPos.xyz, FRAME.camFwd.xyz);
uint z = depth <= kClusterNear ? 0u : min(uint(log(depth / kClusterNear) / log(kClusterFar / kClusterNear) * float(kClusters.z)), kClusters.z - 1u);
uvec2 tile = min(uvec2(gl_FragCoord.xy / FRAME.screen.xy * vec2(kClusters.xy)), kClusters.xy - 1u);
return (z * kClusters.y + tile.y) * kClusters.x + tile.x;
}
// 1 = lit. 3×3 bilinear PCF taps inside the light's tile of the shadow atlas.
float atlasShadow(int view, vec3 world, vec3 n, float dist) {
ShadowView sv = SHADOW_VIEW(view);
vec4 c = sv.viewProj * vec4(world + n * (0.75 + dist * 0.006), 1.0);
if (c.w <= 0.0) return 1.0;
c.xyz /= c.w;
if (any(greaterThan(abs(c.xy), vec2(1.0))) || c.z > 1.0) return 1.0;
float texel = 1.0 / FRAME.screen.w;
vec2 lo = sv.rect.xy + texel, hi = sv.rect.xy + sv.rect.zw - texel;
vec2 at = sv.rect.xy + (c.xy * 0.5 + 0.5) * sv.rect.zw;
uint atlas = uint(FRAME.screen.z);
float lit = 0.0;
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) lit += shadowTap(atlas, at + vec2(x, y) * texel, lo, hi, c.z - 0.00002);
return lit / 9.0;
}
// GGX specular: height-correlated Smith visibility, Schlick Fresnel (F0: g_f0).
// Times π, because our light values are "what a white wall shows" (diffuse = albedo × light).
// Multiply by n·l and the light. rough: perceptual roughness.
// Reflectance at normal incidence for this surface (mesh.frag sets it: 0.04 for dielectrics,
// the albedo for metals).
vec3 g_f0 = vec3(0.04);
vec3 specGGX(vec3 n, vec3 l, vec3 v, float rough) {
vec3 h = normalize(l + v);
float a = max(rough * rough, 0.002), a2 = a * a;
float ndl = max(dot(n, l), 1e-4), ndv = max(dot(n, v), 1e-4), ndh = max(dot(n, h), 0.0), vdh = max(dot(v, h), 0.0);
float q = ndh * ndh * (a2 - 1.0) + 1.0;
float d = a2 / (3.14159265 * q * q);
float vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2));
vec3 f = g_f0 + (1.0 - g_f0) * pow(1.0 - vdh, 5.0);
return min(3.14159265 * d * vis * f, vec3(64.0));
}
// Light reaching the surface from every dynamic light in its cluster: diffuse (returned) and
// the highlights (spec), sharper and stronger the glossier the surface.
vec3 dynamicLights(vec3 world, vec3 n, float rough, vec3 v, out uint count, out vec3 spec) {
count = 0u;
spec = vec3(0.0);
if (FRAME.camPos.w < 0.5) return vec3(0.0);
// A mirror's pass sees from somewhere else than the clusters were made for: every light.
bool every = FRAME.mirror.y > 0.5;
uvec2 cell = every ? uvec2(0u, min(uint(FRAME.camPos.w), 64u)) : CELL(clusterIndex(world));
count = cell.y;
vec3 total = vec3(0.0);
for (uint k = 0u; k < cell.y; ++k) {
Light l = LIGHT(every ? k : LIGHT_INDEX(cell.x + k));
vec3 toLight = l.posRadius.xyz - world;
float dist = length(toLight);
if (dist >= l.posRadius.w) continue;
vec3 L = toLight / max(dist, 0.001);
float ndl = dot(n, L);
if (ndl <= 0.0) continue;
float a = 1.0 - dist / l.posRadius.w;
float atten = a * a;
if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz));
if (atten <= 0.0) continue;
if (l.color.w >= 0.0) {
int view = int(l.color.w);
if (l.spot.w <= -1.5) { // point light: one atlas tile per cube face, +X -X +Y -Y +Z -Z
vec3 d = -toLight, ad = abs(d);
view += ad.x >= ad.y && ad.x >= ad.z ? (d.x > 0.0 ? 0 : 1) : ad.y >= ad.z ? (d.y > 0.0 ? 2 : 3) : (d.z > 0.0 ? 4 : 5);
}
atten *= atlasShadow(view, world, n, dist);
}
total += l.color.rgb * atten * ndl;
// Area light (a sphere of radius extra.y): the highlight comes from the point on it
// nearest the reflected ray, and widens to match (Karis 2013, "representative point").
vec3 Ls = L;
float norm = 1.0;
if (l.extra.y > 0.0) {
vec3 r = reflect(-v, n);
vec3 toRay = dot(toLight, r) * r - toLight;
vec3 closest = toLight + toRay * clamp(l.extra.y / max(length(toRay), 1e-3), 0.0, 1.0);
Ls = normalize(closest);
float a = max(rough * rough, 0.002), a2 = clamp(a + l.extra.y / (2.0 * dist), 0.0, 1.0);
norm = (a / a2) * (a / a2);
}
spec += l.color.rgb * atten * ndl * specGGX(n, Ls, v, rough) * norm;
}
return total;
}
vec3 heatmap(uint count) {
float t = clamp(float(count) / 16.0, 0.0, 1.0);
return count == 0u ? vec3(0.0, 0.0, 0.15) : mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 0.1, 0.0), t);
}

376
engine/shaders/mesh.frag Normal file
View file

@ -0,0 +1,376 @@
#version 460
#include "backend.glsl"
#include "model.glsl"
#include "lights.glsl"
#include "sky.glsl"
layout(location = 0) in vec3 v_normal;
layout(location = 1) in vec2 v_uv;
layout(location = 2) in vec2 v_lightmapUv;
layout(location = 3) in vec3 v_world;
layout(location = 0) out vec4 o_color;
layout(location = 1) out vec4 o_gbuf; // view normal (octahedral), roughness, reflection weight: for ssr.frag
vec2 octEncode(vec3 n) {
n /= abs(n.x) + abs(n.y) + abs(n.z);
if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);
return n.xy * 0.5 + 0.5;
}
// 1 = sunlit. Cascade by view distance, normal offset by the cascade's texel size,
// 3×3 bilinear PCF taps (a smooth 4×4 filter).
float sunShadow(vec3 n) {
if (FRAME.sunColor.w < 0.5) return 1.0;
float depth = dot(v_world - FRAME.camPos.xyz, FRAME.camFwd.xyz);
if (depth > FRAME.splits.w) return 1.0;
int c = depth < FRAME.splits.x ? 0 : depth < FRAME.splits.y ? 1 : depth < FRAME.splits.z ? 2 : 3;
float texel = FRAME.cascadeTexel[c];
vec4 p = FRAME.cascades[c] * vec4(v_world + n * texel * 1.5, 1.0);
vec2 uv = p.xy * 0.5 + 0.5;
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0;
float atlasTexel = FRAME.shadow.x;
vec2 base = vec2(c & 1, c >> 1) * 0.5;
vec2 lo = base + atlasTexel, hi = base + 0.5 - atlasTexel;
vec2 at = base + uv * 0.5;
uint map = uint(FRAME.sunDir.w);
float lit = 0.0;
float soft = FRAME.shadow.w; // the sun's apparent size (r_shadowsoft); 0 = the plain 3x3 filter
if (soft <= 0.0) {
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) lit += shadowTap(map, at + vec2(x, y) * atlasTexel, lo, hi, p.z - 0.0002);
lit /= 9.0;
} else {
// PCSS: how far above this point the things shading it are (blocker search), then a
// filter that widens with that gap, so shadows are sharp where they touch and soft away.
const vec2 disk[12] = vec2[](vec2(-0.326, -0.406), vec2(-0.840, -0.074), vec2(-0.696, 0.457), vec2(-0.203, 0.621),
vec2(0.962, -0.195), vec2(0.473, -0.480), vec2(0.519, 0.767), vec2(0.185, -0.893),
vec2(0.507, 0.064), vec2(0.896, 0.412), vec2(-0.322, -0.933), vec2(-0.792, -0.598));
float spin = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) * 6.2831853;
mat2 rot = mat2(cos(spin), sin(spin), -sin(spin), cos(spin));
float blockers = 0.0, found = 0.0;
for (int i = 0; i < 12; ++i) {
float d = sampleTexLod(map, clamp(at + rot * disk[i] * atlasTexel * 6.0, lo, hi), 0.0).r;
if (d < p.z - 0.0002) blockers += d, found += 1.0;
}
if (found == 0.0) return 1.0;
float gap = (p.z - blockers / found) * 16000.0; // world units (the cascades' depth range)
float width = clamp(gap * 0.012 * soft / texel, 1.0, 8.0); // in shadow texels
for (int i = 0; i < 12; ++i) lit += shadowTap(map, at + rot * disk[i] * atlasTexel * width, lo, hi, p.z - 0.0002);
lit /= 12.0;
}
// Fade out over the last 10% of the shadow distance instead of a hard edge.
float fade = clamp((FRAME.splits.w - depth) / (FRAME.splits.w * 0.1), 0.0, 1.0);
return mix(1.0, lit, fade);
}
// Smooth value noise, for ripples and the ragged edges of puddles.
float hash3(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
float vnoise(vec3 p) {
vec3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y),
mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);
}
// Tangent frame from screen-space derivatives (no stored tangents needed): T along +u, B along +v.
mat3 cotangentFrame(vec3 n, vec3 p, vec2 uv) {
vec3 dp1 = dFdx(p), dp2 = dFdy(p);
vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
vec3 dp2perp = cross(dp2, n), dp1perp = cross(n, dp1);
vec3 t = dp2perp * duv1.x + dp1perp * duv2.x;
vec3 b = dp2perp * duv1.y + dp1perp * duv2.y;
float invmax = inversesqrt(max(max(dot(t, t), dot(b, b)), 1e-20));
return mat3(t * invmax, b * invmax, n);
}
// Image-based light: what the split-sum integral of GGX × Fresnel comes to (F0 scale, bias),
// fitted analytically (Karis) instead of a lookup texture.
vec2 envBRDF(float rough, float ndv) {
const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022), c1 = vec4(1.0, 0.0425, 1.04, -0.04);
vec4 r = rough * c0 + c1;
float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y;
return vec2(-1.04, 1.04) * a004 + r.zw;
}
bool hasSky() { return FRAME.sky.x > 0.5; }
vec3 sky(vec3 dir, float level) { return skyRadiance(uint(FRAME.sky.x), FRAME.sky.y, FRAME.sky.w, dir, level); }
// The sky's light on a surface facing n (irradiance / π), from its spherical harmonics.
vec3 skyLight(vec3 n) {
return FRAME.skySH[0].rgb * 0.282095 + FRAME.skySH[1].rgb * 0.488603 * n.y + FRAME.skySH[2].rgb * 0.488603 * n.z
+ FRAME.skySH[3].rgb * 0.488603 * n.x + FRAME.skySH[4].rgb * 1.092548 * n.x * n.y + FRAME.skySH[5].rgb * 1.092548 * n.y * n.z
+ FRAME.skySH[6].rgb * 0.315392 * (3.0 * n.z * n.z - 1.0) + FRAME.skySH[7].rgb * 1.092548 * n.x * n.z
+ FRAME.skySH[8].rgb * 0.546274 * (n.x * n.x - n.y * n.y);
}
float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
// Height fog / aerial perspective: denser low down, coloured by the sky behind (blurred) and
// brighter towards the sun. Returns the colour seen through `dist` of it.
#ifndef KEEL_GL
#include "froxel.glsl"
#define GI_PROBES FRAME.giProbes
#include "gi.glsl"
#endif
vec3 applyFog(vec3 color, vec3 world) {
vec3 ray = world - FRAME.camPos.xyz;
float dist = length(ray);
vec3 rd = ray / max(dist, 1e-3);
if (!hasSky() || FRAME.fog.x <= 0.0) return color;
float k = FRAME.fog.y;
float start = FRAME.fog.x * exp(-k * (FRAME.camPos.z - FRAME.fog.w));
float kz = k * rd.z;
float amount = start * (abs(kz) > 1e-6 ? (1.0 - exp(-kz * dist)) / kz : dist);
float f = min(1.0 - exp(-amount), FRAME.fog.z);
vec3 fogColor = sky(normalize(vec3(rd.xy, max(rd.z, 0.05))), FRAME.sky.z) * 0.9;
if (FRAME.sunColor.w > 0.5) fogColor += FRAME.sunColor.rgb * pow(max(dot(rd, FRAME.sunDir.xyz), 0.0), 8.0) * 0.25;
return mix(color, fogColor, f);
}
vec3 ambientCube(vec3 n) {
vec3 w = n * n;
return w.x * (n.x > 0.0 ? DRAW.cube[0].rgb : DRAW.cube[1].rgb)
+ w.y * (n.y > 0.0 ? DRAW.cube[2].rgb : DRAW.cube[3].rgb)
+ w.z * (n.z > 0.0 ? DRAW.cube[4].rgb : DRAW.cube[5].rgb);
}
void main() {
// A mirror's pass: nothing behind the mirror (the wall it's on, the room beyond).
if (dot(FRAME.clip.xyz, FRAME.clip.xyz) > 0.5 && dot(v_world, FRAME.clip.xyz) < FRAME.clip.w) discard;
// The mirror itself: the picture its pass made, where this pixel is (slightly darkened, as
// real mirrors are), and nothing for the screen-space reflections to add.
if (FRAME.mirror.x > 0.5 && (pc.textures & 0xFFFFu) == uint(FRAME.mirror.x)) {
o_color = vec4(fetchTex(uint(FRAME.mirror.x), ivec2(gl_FragCoord.xy)).rgb * vec3(0.9, 0.92, 0.92), 1.0);
o_gbuf = vec4(0.5, 0.5, 1.0, 0.0);
return;
}
vec4 albedo = sampleTex(pc.textures & 0xFFFFu, v_uv);
if (albedo.a < 0.5) discard; // alpha-tested texture (fences, foliage)
vec4 tint = unpackUnorm4x8(pc.tint);
vec3 base = albedo.rgb * tint.rgb;
vec3 n = normalize(v_normal);
vec3 geo = n; // the surface itself, before relief
vec3 view = normalize(FRAME.camPos.xyz - v_world);
// Relief and gloss from the material map. Derivatives first, while control flow is uniform.
mat3 tbn = cotangentFrame(n, v_world, v_uv);
float gloss = 0.15, metal = 0.0;
uint materialMap = pc.material & 0xFFFFu, skyVisMap = pc.material >> 16;
if (materialMap != 0u) {
vec4 m = sampleTex(materialMap, v_uv);
vec2 xy = m.xy * 2.0 - 1.0; // X, Y; Z rebuilt (B holds metalness)
n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0))));
metal = m.z;
gloss = m.w;
}
// Weather: how open to the sky this spot is (the lightmap's sky map; moving things: their
// sun visibility), then rain wetting it (darker, glossy) and snow lying on what faces up.
float exposed = skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : DRAW.params.y > 0.5 ? DRAW.params.x : 0.0;
exposed = smoothstep(0.25, 0.7, exposed);
if (FRAME.weather.x > 0.0) {
float wet = FRAME.weather.x * exposed * (0.45 + 0.55 * max(geo.z, 0.0));
base *= 1.0 - 0.45 * wet;
gloss = mix(gloss, 0.93, wet * 0.85);
}
float snowCover = 0.0;
if (FRAME.weather.y > 0.0) {
float snow = FRAME.weather.y * exposed * smoothstep(0.35, 0.8, geo.z) * smoothstep(0.2, 0.6, vnoise(v_world * 0.05) * 0.5 + FRAME.weather.y);
snowCover = snow;
// Snow isn't flat white: drifts a little brighter, hollows a little bluer.
float drift = vnoise(v_world * 0.012) * 0.6 + vnoise(v_world * 0.05) * 0.4;
base = mix(base, mix(vec3(0.78, 0.82, 0.9), vec3(0.9, 0.91, 0.93), drift), snow);
gloss = mix(gloss, 0.25, snow);
metal *= 1.0 - snow;
}
int liquid = int(DRAW.params.w + 0.5);
float liquidAlpha = 1.0;
if (liquid == 1) {
// A film of liquid: thickest in the middle (UV v = 0), ragged at the edge, rippled.
float thickness = 1.0 - v_uv.y;
float ragged = vnoise(v_world * 0.35) * 0.6 + vnoise(v_world * 1.1) * 0.4;
float edge = 0.12 + 0.45 * ragged;
if (thickness < edge) discard;
liquidAlpha = smoothstep(edge, edge + 0.2, thickness);
float e = 0.05;
vec3 q = v_world * 0.6;
vec3 grad = vec3(vnoise(q + vec3(e, 0, 0)) - vnoise(q - vec3(e, 0, 0)), vnoise(q + vec3(0, e, 0)) - vnoise(q - vec3(0, e, 0)),
vnoise(q + vec3(0, 0, e)) - vnoise(q - vec3(0, 0, e))) / (2.0 * e);
n = normalize(n - (grad - n * dot(grad, n)) * 0.12);
// Deeper liquid is richer in colour (Beer–Lambert-ish); thin edges just wet the surface.
base = mix(base * 0.35, base, 0.4 + 0.6 * thickness);
gloss = 0.95;
} else if (liquid == 2) {
// A body of liquid: light passes through it, more of it where it's thicker. Looking
// straight through the middle is the longest path (rich, dense); the edges are thin.
float through = abs(dot(n, view));
base = mix(base * 1.15, base * 0.5, through);
liquidAlpha = mix(0.45, 1.0, through);
gloss = 0.92;
}
if (liquid == 4) {
// A decal (a burn mark) on a disc: matte, darkest in the middle, its edge soft and ragged.
float thickness = 1.0 - v_uv.y;
float ragged = vnoise(v_world * 0.12) * 0.6 + vnoise(v_world * 0.45) * 0.4;
liquidAlpha = smoothstep(0.0, 0.9, thickness + (ragged - 0.5) * 0.6);
gloss = 0.08;
}
if (liquid == 3) {
// Open water: waves (a few travelling swells plus noise ripples) bend the normal; looking
// straight down you see into it, at a grazing angle it mirrors the sky (Fresnel, below).
float t = DRAW.probe.z;
vec2 p = v_world.xy, g = vec2(0.0);
vec2 d1 = vec2(0.8, 0.6), d2 = vec2(-0.5, 0.86), d3 = vec2(0.2, -0.98);
g += d1 * cos(dot(p, d1) * 0.035 + t * 1.1) * 0.09;
g += d2 * cos(dot(p, d2) * 0.07 + t * 1.7) * 0.06;
g += d3 * cos(dot(p, d3) * 0.13 + t * 2.3) * 0.04;
// Two layers of ripples drifting different ways (the wind, and the surface's own churn).
float e = 0.5;
vec3 q = vec3(p * 0.09 + vec2(t * 0.25, t * 0.1), t * 0.35), q2 = vec3(p * 0.21 - vec2(t * 0.12, t * 0.3), t * 0.6);
g += vec2(vnoise(q + vec3(e, 0, 0)) - vnoise(q - vec3(e, 0, 0)), vnoise(q + vec3(0, e, 0)) - vnoise(q - vec3(0, e, 0))) * 0.3;
g += vec2(vnoise(q2 + vec3(e, 0, 0)) - vnoise(q2 - vec3(e, 0, 0)), vnoise(q2 + vec3(0, e, 0)) - vnoise(q2 - vec3(0, e, 0))) * 0.18;
n = normalize(geo + vec3(-g, 0.0));
float through = abs(dot(n, view));
base = mix(base * 1.25, base * 0.6, through);
liquidAlpha = mix(0.95, 0.62, through);
gloss = 0.97;
}
if (DRAW.params.z > 0.5) {
// Liquid: cut away above the surface; looking in through the cut, the inside (back)
// faces stand in for the flat surface.
if (dot(v_world, DRAW.plane.xyz) > DRAW.plane.w) discard;
if (!gl_FrontFacing) {
n = DRAW.plane.xyz;
base *= 1.2;
}
}
// Metals reflect in their own colour and scatter nothing: F0 is the albedo, diffuse goes.
g_f0 = mix(vec3(0.04), base, metal);
base *= 1.0 - metal;
vec3 sunDir = FRAME.sunDir.xyz;
// Relief can't light what the surface itself faces away from (no light leaking round corners).
float facing = dot(geo, sunDir) > 0.0 ? max(dot(n, sunDir), 0.0) : 0.0;
vec3 sun = FRAME.sunColor.rgb * FRAME.sunColor.w * facing;
float rough = clamp(1.0 - gloss, 0.05, 1.0);
vec3 sunSpec = FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, rough);
// How much open sky this spot sees (reflections of the sky fade with it).
float skyVis = 0.0;
// Cavities in the relief (grout, pores) get less of the soft, baked light.
float cavity = clamp(1.0 - (1.0 - dot(n, geo)) * 2.5, 0.45, 1.0);
vec3 specular = vec3(0.0), env = vec3(0.0);
bool realtime = FRAME.shadow.z > 0.5;
uint lightmap = pc.textures >> 16;
vec3 light;
if (lightmap != 0u) {
vec4 rgbm = sampleTex(lightmap, v_lightmapUv); // RGBM, range 8
vec3 baked = rgbm.rgb * rgbm.a * 8.0 * cavity;
// Night: what the sky gave (most of the light where it's open) goes; lamps stay.
baked *= 1.0 - FRAME.weather.z * 0.9 * smoothstep(0.0, 0.45, skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : 0.0);
env = baked;
skyVis = skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : 0.0;
if (realtime) {
float vis = facing > 0.0 ? sunShadow(geo) : 0.0;
light = baked + sun * vis;
specular = sunSpec * vis;
} else { // old maps: the sun is baked in; players and props darken it where they block it
float shade = 1.0 - FRAME.shadow.y * (1.0 - sunShadow(n)) * facing * FRAME.sunColor.w;
light = baked * shade;
}
} else if (DRAW.params.y > 0.5) {
// Moving things: baked ambient cube plus the sun. With a real-time sun the shadow map
// already has the world in it; otherwise the probe's sun visibility stands in for it.
float vis = facing > 0.0 ? sunShadow(geo) : 0.0;
vis = realtime ? vis : DRAW.params.x * vis;
env = ambientCube(n) * cavity * (1.0 - FRAME.weather.z * 0.8 * max(DRAW.params.x, 0.35));
light = env + sun * vis;
// Probes don't know where the sky is: compare their light with the open sky's.
if (hasSky()) skyVis = clamp(luma(env) / max(luma(skyLight(n)), 1e-3), 0.0, 1.0) * mix(0.6, 1.0, DRAW.params.x);
specular = sunSpec * vis;
} else if (hasSky()) {
// No baked light of its own (water, map-less things): the sky's light here and the real
// sun or moon, through its shadow — so it darkens at night like everything else.
float vis = facing > 0.0 ? sunShadow(geo) : 0.0;
env = skyLight(n);
light = env + sun * vis;
specular = sunSpec * vis;
} else {
light = vec3(sunLight(n));
}
uint count;
vec3 dynSpec;
light += dynamicLights(v_world, n, rough, view, count, dynSpec);
#ifndef KEEL_GL
if (FRAME.gi.x > 0.5) {
vec3 bounced = giIrradiance(v_world, n, view) * cavity; // their light, bounced
if (FRAME.gi.z > 0.5) { o_color = vec4(bounced * 4.0, 1.0); o_gbuf = vec4(0.0); return; }
light += bounced;
}
#endif
// Reflections (split-sum image-based light): the sky, prefiltered for this roughness, where
// the sky is open; the light arriving here stands in for the room where it isn't.
float ndv = clamp(dot(n, view), 1e-4, 1.0);
vec3 r = reflect(-view, n);
r = normalize(r + geo * max(-dot(r, geo), 0.0) * 1.05); // keep it above the surface
vec2 ab = envBRDF(rough, ndv);
float occlusion = skyVis * skyVis;
// What's around: the nearest reflection probe (env_cubemap) where there is one, else the
// light arriving here; the sky takes over as far as this spot sees it.
// (probes were baked in daylight: at night what they show goes dark too)
vec3 near = DRAW.probe.x > 0.5 ? skyRadiance(uint(DRAW.probe.x), 1.0 - 0.92 * FRAME.weather.z, 0.0, r, rough * DRAW.probe.y) : env;
vec3 surroundings = hasSky() ? mix(near, sky(r, rough * FRAME.sky.z), occlusion) : near;
vec3 reflection = surroundings * (g_f0 * ab.x + ab.y) * cavity;
vec3 color = base * light + specular + dynSpec + reflection;
// Snow glitters: here and there a crystal catches the sun (or a lamp) towards the eye.
if (snowCover > 0.05) {
vec3 cell = floor(v_world * 1.7);
float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 45.164))) * 43758.5453);
float glint = step(0.975, h) * pow(max(dot(n, normalize(sunDir + view)), 0.0), 40.0);
color += (FRAME.sunColor.rgb * FRAME.sunColor.w * (facing > 0.0 ? 1.0 : 0.0) + dynSpec * 4.0) * glint * snowCover * 1.2;
}
float alpha = tint.a * liquidAlpha;
if (liquid == 3) {
// Open water barely scatters light of its own: it's what's mirrored (more the flatter
// you look: Fresnel) over what's seen through it (the bottom, more looking straight
// down), tinted by a dim veil of its own colour. Blended over what's behind it, so:
// alpha = the share it covers, colour = what covers it.
vec3 mirrored = hasSky() ? mix(env, sky(r, 0.0), occlusion) : vec3(0.45, 0.6, 0.8);
float fres = 0.02 + 0.98 * pow(1.0 - clamp(abs(dot(n, view)), 0.0, 1.0), 5.0);
float clear = 0.72 * clamp(abs(dot(geo, view)), 0.0, 1.0); // (how much of the bottom shows through)
vec3 veil = base * (light * 0.3 + vec3(0.015));
float cover = fres + (1.0 - fres) * (1.0 - clear);
color = (mirrored * fres + veil * (1.0 - fres) * (1.0 - clear)) / max(cover, 1e-3);
color += FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, 0.06) + dynSpec; // (the sun's glint, lamps')
alpha = clamp(cover, 0.0, 1.0);
} else if (liquid != 0 && liquid != 4) {
// Wet: a sharp sun glint and the sky mirrored at grazing angles, on top of what's lit.
vec3 mirrored = hasSky() ? mix(env, sky(r, 0.0), occlusion) : vec3(0.45, 0.6, 0.8);
float fres = 0.02 + 0.98 * pow(1.0 - clamp(dot(n, view), 0.0, 1.0), 5.0);
color += mirrored * fres * 0.9 + FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, 0.08);
if (liquid == 1) alpha = clamp(alpha * (0.75 + fres), 0.0, 0.95);
}
if (alpha < 0.5 && liquid != 4 && liquid != 3) {
// Glass (clear materials; denser translucent things like liquid or cracks shade normally): more reflective at grazing angles (Fresnel), plus a sharp sun glint.
vec3 v = normalize(FRAME.camPos.xyz - v_world);
vec3 nf = dot(n, v) < 0.0 ? -n : n; // the side facing us, whatever the winding
float fresnel = pow(1.0 - clamp(dot(nf, v), 0.0, 1.0), 4.0);
float glint = pow(max(dot(nf, normalize(sunDir + v)), 0.0), 150.0) * FRAME.sunColor.w;
// Glass barely scatters light itself: mostly what's behind it, plus reflections.
vec3 rg = reflect(-v, nf);
vec3 mirrored = hasSky() ? mix(env, sky(rg, 0.0), max(occlusion, 0.25)) : vec3(0.45, 0.6, 0.8);
color = color * 0.12 + FRAME.sunColor.rgb * glint * 3.0 + mirrored * fresnel * 0.8;
// A neutral sheen where it turns away from you (no colour: grey to white), so glass reads
// as glass at an angle.
color += vec3(0.06 + fresnel * 0.35) * (hasSky() ? max(occlusion, 0.4) : 0.6);
alpha = clamp(alpha * 0.6 + fresnel * 0.5 + glint, 0.0, 1.0);
}
#ifndef KEEL_GL
if (FRAME.froxel.x > 0.5) { // the fog grid: lit, shadowed fog (and the air's light)
vec4 f = froxelFog(gl_FragCoord.xy / FRAME.screen.xy, dot(v_world - FRAME.camPos.xyz, FRAME.camFwd.xyz));
color = color * f.a + f.rgb;
} else
#endif
color = applyFog(color, v_world);
o_color = vec4(color, alpha);
// View space as the post passes see it: x right, y down the screen, z forward.
vec3 fwd = FRAME.camFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec3 nv = vec3(dot(n, right), -dot(n, up), dot(n, fwd));
float reflectWeight = dot(g_f0 * ab.x + ab.y, vec3(0.333)) * cavity * (liquid != 0 ? 1.5 : 1.0);
o_gbuf = vec4(octEncode(nv), rough, reflectWeight);
if (FRAME.camFwd.w > 0.5) o_color.rgb = mix(o_color.rgb, heatmap(count), 0.6);
}

18
engine/shaders/mesh.vert Normal file
View file

@ -0,0 +1,18 @@
#version 460
#include "model.glsl"
layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec2 v_lightmapUv;
layout(location = 3) out vec3 v_world;
void main() {
Vertex vx = VERTEX(VERTEX_ID);
mat4 model = DRAW.model;
vec4 world = model * vec4(vx.px, vx.py, vx.pz, 1.0);
v_normal = normalize(mat3(model) * vec3(vx.nx, vx.ny, vx.nz));
v_uv = vec2(vx.u, vx.v);
v_lightmapUv = vec2(vx.lu, vx.lv);
v_world = world.xyz;
gl_Position = pc.viewProj * world;
}

169
engine/shaders/model.glsl Normal file
View file

@ -0,0 +1,169 @@
#ifndef KEEL_MODEL_GLSL
#define KEEL_MODEL_GLSL
// Shared by mesh.vert, skinned.vert and mesh.frag: per-draw and per-frame data. The layouts are
// identical on both backends (render/backend.h mirrors them); only how they're reached differs.
#include "backend.glsl"
struct Vertex { float px, py, pz, nx, ny, nz, u, v, lu, lv; };
// Dynamic lights (clustered, see render/clusters.h).
struct Light {
vec4 posRadius;
vec4 color; // rgb, w = first shadow view or -1
vec4 spot; // xyz direction, w = cos(outer cone); -2 = point light
vec4 extra; // x = cos(inner cone), y = the light's size (area light radius)
};
struct ShadowView { mat4 viewProj; vec4 rect; }; // rect: atlas uv offset, size
// How much of a spot light reaches a direction at cos c from its axis. A plain spot fades from
// its inner cone to its outer one; a reflector (a flashlight, extra.z = 1) has a hot centre, a
// faint bright ring where its mirror ends, faint rings in the beam and a dim spill out to the edge.
float spotShape(Light l, float c) {
if (l.extra.z < 0.5) return smoothstep(l.spot.w, l.extra.x, c);
float outer = acos(clamp(l.spot.w, -1.0, 1.0)), inner = acos(clamp(l.extra.x, -1.0, 1.0));
float t = acos(clamp(c, -1.0, 1.0)) / max(outer, 1e-3), k = inner / max(outer, 1e-3);
float core = mix(0.14, 1.0, 1.0 - smoothstep(k * 0.5, k * 1.1, t));
float ring = exp(-pow((t - k * 1.15) * 16.0, 2.0)) * 0.22;
float rings = 0.95 + 0.05 * sin(t * 60.0);
return (core + ring) * rings * (1.0 - smoothstep(0.7, 1.0, t));
}
// Per draw: transform, baked ambient cube (+X -X +Y -Y +Z -Z), params x = sun visibility,
// y = lit by probes, z = liquid (cut at `plane`: xyz normal, w distance, world space),
// w = liquid style (1 film, 2 body; see LiquidStyle).
#define DRAW_FIELDS mat4 model; vec4 cube[6]; vec4 params; vec4 plane; vec4 probe;
// Per frame: sun and its shadow map, camera, dynamic lights.
#define FRAME_FIELDS \
mat4 cascades[4]; /* sun shadow cascades, each a quarter of the shadow map */ \
vec4 splits; /* view distance where each cascade ends */ \
vec4 cascadeTexel; /* world size of a shadow texel per cascade */ \
vec4 sunDir; /* xyz towards the sun, w = shadow map texture index */ \
vec4 sunColor; /* rgb, w = 1 when there is a sun */ \
vec4 shadow; /* x = texel size, y = strength on baked surfaces, z = 1 real-time */ \
vec4 volumetric; /* volumetric light: x strength, y reach, z air density, w sun too */ \
vec4 camPos; /* xyz, w = dynamic light count */ \
vec4 camFwd; /* xyz, w = debug view (1 = lights per cluster) */ \
vec4 screen; /* width, height, shadow atlas texture index, atlas size */ \
vec4 sky; /* x sky texture (0 = none), y intensity, z top level, w yaw */ \
vec4 fog; /* x density per unit, y height falloff per unit, z max, w base z */ \
vec4 skySH[9]; /* the sky's light: rgb spherical harmonics, irradiance / pi */ \
vec4 froxel; /* the fog grid (froxel.glsl): x 1 = on (Vulkan), y near, z far */ \
vec4 gi; /* real-time GI probes (gi.glsl): x 1 = on, y spacing */ \
vec4 giOrigin; /* the probe grid's first cell (whole numbers) */ \
vec4 mirror; /* x: the mirror's picture's texture (a mirror surface shows it); y 1: \
the mirror's own pass (every light, not this view's clusters) */ \
vec4 clip; /* the mirror's pass: what's behind its plane (xyz·p < w) is cut */ \
vec4 weather; /* x wet (rain), y snow cover, z night (0 day .. 1), w lightning flash */
#ifdef KEEL_FRAME_ONLY
// Post passes that need the frame (volumetric.frag): it and the dynamic lights. Vulkan: a
// FrameData reference in the pass's own push block (#define FRAME pc.frame there); OpenGL: the
// frame's and the lights' bindings.
#ifdef KEEL_GL
layout(std430, binding = 4) readonly buffer FrameBlock { FRAME_FIELDS uvec2 unusedRefs[6]; } u_frame;
layout(std430, binding = 5) readonly buffer Lights { Light l[]; } u_lights;
layout(std430, binding = 6) readonly buffer Cells { uvec2 c[]; } u_cells;
layout(std430, binding = 7) readonly buffer LightIndices { uint i[]; } u_lightIndices;
layout(std430, binding = 8) readonly buffer ShadowViews { ShadowView v[]; } u_shadowViews;
#define FRAME u_frame
#define LIGHT(n_) u_lights.l[n_]
#define CELL(n_) u_cells.c[n_]
#define LIGHT_INDEX(n_) u_lightIndices.i[n_]
#define SHADOW_VIEW(n_) u_shadowViews.v[n_]
#else
layout(buffer_reference, std430) readonly buffer Lights { Light l[]; };
layout(buffer_reference, std430) readonly buffer Cells { uvec2 c[]; };
layout(buffer_reference, std430) readonly buffer LightIndices { uint i[]; };
layout(buffer_reference, std430) readonly buffer ShadowViews { ShadowView v[]; };
layout(buffer_reference, std430) readonly buffer Froxels { uvec2 f[]; }; // the fog grid, integrated: rgb light, a transmittance (halves)
// A GI probe (gi.glsl): the light reaching it as L1 spherical harmonics (sh[0..3].rgb: band 0,
// then y, z, x; sh[0].w how much to trust it), the mean distance to what's around it and its
// square along ±x ±y ±z, and the world cell it holds (w 1 = filled).
struct GiProbe { vec4 sh[4]; vec4 dist[3]; vec4 tag; };
layout(buffer_reference, std430) readonly buffer GiProbes { GiProbe p[]; };
layout(buffer_reference, std430) readonly buffer FrameData {
FRAME_FIELDS
Lights lights;
Cells cells;
LightIndices lightIndices;
ShadowViews shadowViews;
Froxels froxels;
GiProbes giProbes;
};
#define LIGHT(n_) FRAME.lights.l[n_]
#define CELL(n_) FRAME.cells.c[n_]
#define LIGHT_INDEX(n_) FRAME.lightIndices.i[n_]
#define SHADOW_VIEW(n_) FRAME.shadowViews.v[n_]
#endif
#elif defined(KEEL_GL)
struct DrawData { DRAW_FIELDS };
layout(std430, binding = 0) readonly buffer Vertices { Vertex v[]; } u_vertices;
layout(std430, binding = 1) readonly buffer Skin { uvec2 s[]; } u_skin; // joints (4×u8), weights (4×u8)
layout(std430, binding = 2) readonly buffer Joints { mat4 m[]; } u_joints;
layout(std430, binding = 3) readonly buffer Draws { DrawData d[]; } u_draws;
layout(std430, binding = 4) readonly buffer FrameBlock { FRAME_FIELDS uvec2 unusedRefs[6]; } u_frame;
layout(std430, binding = 5) readonly buffer Lights { Light l[]; } u_lights;
layout(std430, binding = 6) readonly buffer Cells { uvec2 c[]; } u_cells; // first index, count
layout(std430, binding = 7) readonly buffer LightIndices { uint i[]; } u_lightIndices;
layout(std430, binding = 8) readonly buffer ShadowViews { ShadowView v[]; } u_shadowViews;
PUSH_BLOCK {
mat4 viewProj;
uint textures; // albedo | lightmap << 16
uint tint; // RGBA8
uint material; // texture: rgb tangent-space normal, a gloss (0 = flat, matte-ish) | sky visibility << 16
uint drawIndex; // into Draws
uint firstJoint; // into Joints (skinned draws)
} pc;
#define VERTEX(n_) u_vertices.v[n_]
#define SKIN(n_) u_skin.s[n_]
#define JOINT(n_) u_joints.m[pc.firstJoint + (n_)]
#define DRAW u_draws.d[pc.drawIndex]
#define FRAME u_frame
#define LIGHT(n_) u_lights.l[n_]
#define CELL(n_) u_cells.c[n_]
#define LIGHT_INDEX(n_) u_lightIndices.i[n_]
#define SHADOW_VIEW(n_) u_shadowViews.v[n_]
#else
layout(buffer_reference, std430) readonly buffer Vertices { Vertex v[]; };
layout(buffer_reference, std430) readonly buffer Skin { uvec2 s[]; }; // joints (4×u8), weights (4×u8)
layout(buffer_reference, std430) readonly buffer Joints { mat4 m[]; };
layout(buffer_reference, std430) readonly buffer DrawData { DRAW_FIELDS };
layout(buffer_reference, std430) readonly buffer Lights { Light l[]; };
layout(buffer_reference, std430) readonly buffer Cells { uvec2 c[]; }; // first index, count
layout(buffer_reference, std430) readonly buffer LightIndices { uint i[]; };
layout(buffer_reference, std430) readonly buffer ShadowViews { ShadowView v[]; };
layout(buffer_reference, std430) readonly buffer Froxels { uvec2 f[]; }; // the fog grid, integrated: rgb light, a transmittance (halves)
// A GI probe (gi.glsl): the light reaching it as L1 spherical harmonics (sh[0..3].rgb: band 0,
// then y, z, x; sh[0].w how much to trust it), the mean distance to what's around it and its
// square along ±x ±y ±z, and the world cell it holds (w 1 = filled).
struct GiProbe { vec4 sh[4]; vec4 dist[3]; vec4 tag; };
layout(buffer_reference, std430) readonly buffer GiProbes { GiProbe p[]; };
layout(buffer_reference, std430) readonly buffer FrameData {
FRAME_FIELDS
Lights lights;
Cells cells;
LightIndices lightIndices;
ShadowViews shadowViews;
Froxels froxels;
GiProbes giProbes;
};
PUSH_BLOCK {
mat4 viewProj;
Vertices vertices;
Skin skin; // skinned draws only
Joints joints; // skinned draws only; already include the model transform
DrawData draw;
FrameData frame;
uint textures; // albedo | lightmap << 16
uint tint; // RGBA8
uint material; // texture: rgb tangent-space normal, a gloss (0 = flat, matte-ish) | sky visibility << 16
} pc;
#define VERTEX(n_) pc.vertices.v[n_]
#define SKIN(n_) pc.skin.s[n_]
#define JOINT(n_) pc.joints.m[n_]
#define DRAW pc.draw
#define FRAME pc.frame
#define LIGHT(n_) pc.frame.lights.l[n_]
#define CELL(n_) pc.frame.cells.c[n_]
#define LIGHT_INDEX(n_) pc.frame.lightIndices.i[n_]
#define SHADOW_VIEW(n_) pc.frame.shadowViews.v[n_]
#endif
#endif

View file

@ -0,0 +1,21 @@
#version 460
#include "model.glsl"
layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec2 v_lightmapUv;
layout(location = 3) out vec3 v_world;
void main() {
Vertex vx = VERTEX(VERTEX_ID);
uvec2 sk = SKIN(VERTEX_ID);
vec4 w = unpackUnorm4x8(sk.y);
mat4 m = JOINT(sk.x & 0xFFu) * w.x + JOINT((sk.x >> 8) & 0xFFu) * w.y
+ JOINT((sk.x >> 16) & 0xFFu) * w.z + JOINT(sk.x >> 24) * w.w;
vec4 world = m * vec4(vx.px, vx.py, vx.pz, 1.0);
v_normal = normalize(mat3(m) * vec3(vx.nx, vx.ny, vx.nz));
v_uv = vec2(vx.u, vx.v);
v_lightmapUv = vec2(0.0);
v_world = world.xyz;
gl_Position = pc.viewProj * world;
}

58
engine/shaders/sky.frag Normal file
View file

@ -0,0 +1,58 @@
#version 460
#include "backend.glsl"
PUSH_BLOCK {
mat4 invViewProj;
vec4 sunDir; // xyz towards the sun
vec4 sunColor; // rgb, w = 1 when there is a sun
vec4 sky; // x texture, y intensity, z top level, w yaw
vec4 weather; // x cloud cover, y the photo's own sun's height (z; 2 = none), z night, w lightning flash
} pc;
#include "sky.glsl"
layout(location = 0) in vec2 v_ndc;
layout(location = 0) out vec4 o_color;
void main() {
vec4 far = pc.invViewProj * vec4(v_ndc, 1.0, 1.0), near = pc.invViewProj * vec4(v_ndc, 0.0, 1.0);
vec3 dir = normalize(far.xyz / far.w - near.xyz / near.w);
vec3 c = skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, dir, 0.0);
// Overcast: the photo's sky greys over (a flat, brighter-low-down cloud deck).
float cloud = pc.weather.x;
// The photo's own sun (turned to the real sun's bearing, but at the height it was shot at):
// when the time of day has moved the sun away from it, it's painted over with the sky beside it.
if (pc.weather.y < 1.5 && length(pc.sunDir.xy) > 1e-3) {
vec3 photoSun = vec3(normalize(pc.sunDir.xy) * sqrt(max(1.0 - pc.weather.y * pc.weather.y, 0.0)), pc.weather.y);
float moved = 1.0 - smoothstep(0.9990, 0.9998, dot(normalize(pc.sunDir.xyz), photoSun));
float near = smoothstep(0.975, 0.997, dot(dir, photoSun)) * moved;
if (near > 0.0) { // the same height a little round to the side: clean sky like what's round it
const float t = 0.38; // ~22°
vec3 beside = vec3(dir.x * cos(t) - dir.y * sin(t), dir.x * sin(t) + dir.y * cos(t), dir.z);
c = mix(c, skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, beside, 1.0), near);
}
}
// The photo blurred right out (its top level): no sun in it, for the cloud deck and the night.
vec3 soft = (cloud > 0.0 || pc.weather.z > 0.0) ? skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, dir, pc.sky.z) : c;
if (cloud > 0.0) {
float l = dot(soft, vec3(0.2126, 0.7152, 0.0722));
vec3 deck = vec3(l) * vec3(0.92, 0.95, 1.0) * mix(1.0, 0.7, smoothstep(0.0, 0.6, dir.z));
c = mix(c, deck, cloud);
}
if (pc.sunColor.w > 0.5 && cloud < 0.95) {
// The sun itself (taken out of the photo so it matches the shadows): a disc and a glow.
float d = dot(dir, pc.sunDir.xyz);
c += pc.sunColor.rgb * (smoothstep(0.99995, 0.99999, d) * 60.0 + pow(max(d, 0.0), 600.0) * 1.5 + pow(max(d, 0.0), 40.0) * 0.08) * (1.0 - cloud) * (1.0 - cloud);
}
// Night: the photo's sky goes dark blue, and stars come out (fixed to the sky).
float night = pc.weather.z;
if (night > 0.0) {
c = mix(c, vec3(0.004, 0.006, 0.014) + soft * 0.15, night);
vec3 d = dir * 220.0;
vec3 cell = floor(d), f = fract(d) - 0.5;
float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
float star = step(0.985, h) * smoothstep(0.12, 0.0, length(f)) * smoothstep(-0.05, 0.25, dir.z);
float bright = 0.3 + 0.7 * fract(h * 97.0); // stars of different brightness
c += vec3(0.8, 0.85, 1.0) * star * bright * night * 0.6;
}
c *= 1.0 + pc.weather.w * 4.0; // lightning lights the clouds
o_color = vec4(c, 1.0);
}

16
engine/shaders/sky.glsl Normal file
View file

@ -0,0 +1,16 @@
#ifndef KEEL_SKY_GLSL
#define KEEL_SKY_GLSL
// The sky (.ksky, engine/asset/ksky.h): equirectangular RGBM; level k is the sky as a surface
// of roughness k / top reflects it.
const float kSkyRange = 16.0;
// Radiance from direction `dir` (world space). yaw turns the photo so its sun lines up with
// the map's.
vec3 skyRadiance(uint tex, float intensity, float yaw, vec3 dir, float level) {
float c = cos(yaw), s = sin(yaw);
dir = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z);
vec2 uv = vec2(atan(dir.y, dir.x) * 0.15915494 + 0.5, acos(clamp(dir.z, -1.0, 1.0)) * 0.31830989);
vec4 m = sampleTexLod(tex, uv, level);
return m.rgb * m.a * kSkyRange * intensity;
}
#endif

10
engine/shaders/sky.vert Normal file
View file

@ -0,0 +1,10 @@
#version 460
// The sky: a full-screen triangle on the far plane, drawn where nothing else is (depth test
// less-or-equal against the cleared 1.0).
#include "backend.glsl"
layout(location = 0) out vec2 v_ndc;
void main() {
vec2 uv = vec2((VERTEX_ID << 1) & 2, VERTEX_ID & 2);
v_ndc = uv * 2.0 - 1.0;
gl_Position = vec4(v_ndc, 1.0, 1.0);
}

View file

@ -0,0 +1,30 @@
#version 460
// Smoke in front of the sky (Vulkan): the sky pass has no fog (its photo has its own haze), so
// smoke against open sky is laid over it here, from the fog grid's smoke-only column totals
// (froxel_main.glsl: light, and how much of what's behind gets through). Drawn on the far plane
// after the sky, where nothing else is: colour × through + light.
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
layout(buffer_reference, std430) readonly buffer SmokeSky { uvec2 f[]; };
PUSH_BLOCK {
FrameData frame;
SmokeSky sky; // 160 × 90 (the fog grid's columns)
} pc;
#define FRAME pc.frame
layout(location = 0) in vec2 v_ndc;
layout(location = 0) out vec4 o_color;
const ivec2 kCols = ivec2(160, 90);
vec4 column(ivec2 c) {
uvec2 v = pc.sky.f[clamp(c.y, 0, kCols.y - 1) * kCols.x + clamp(c.x, 0, kCols.x - 1)];
return vec4(unpackHalf2x16(v.x), unpackHalf2x16(v.y));
}
void main() {
vec2 g = gl_FragCoord.xy / FRAME.screen.xy * vec2(kCols) - 0.5;
ivec2 b = ivec2(floor(g));
vec2 f = g - vec2(b);
vec4 s = mix(mix(column(b), column(b + ivec2(1, 0)), f.x), mix(column(b + ivec2(0, 1)), column(b + ivec2(1, 1)), f.x), f.y);
o_color = vec4(s.rgb, s.a); // (blended: src + dst × src.a)
}

View file

@ -0,0 +1,19 @@
#version 460
// Particles: unlit, the texture times the colour (fire, sparks, flashes added on; smoke blended).
#include "backend.glsl"
PUSH_BLOCK {
mat4 viewProj;
vec4 posSize;
vec4 color;
vec4 right;
vec4 up;
} pc;
layout(location = 0) in vec2 v_uv;
layout(location = 0) out vec4 o_color;
void main() {
vec4 t = sampleTex(floatBitsToUint(pc.up.w), v_uv);
// Block compression leaves a little alpha where there should be none; bright sprites
// would show it as a square: cut it.
float a = clamp((t.a - 0.03) / 0.97, 0.0, 1.0);
o_color = vec4(t.rgb * pc.color.rgb, a * pc.color.a);
}

View file

@ -0,0 +1,21 @@
#version 460
// Particles: a camera-facing quad per sprite, from the vertex index (6 vertices, no buffer).
#include "backend.glsl"
PUSH_BLOCK {
mat4 viewProj;
vec4 posSize; // xyz centre, w half size
vec4 color; // rgb (HDR: can be bright), a
vec4 right; // camera right, w = rotation (radians)
vec4 up; // camera up, w = texture index (bits)
} pc;
layout(location = 0) out vec2 v_uv;
const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1));
void main() {
vec2 q = kQuad[VERTEX_ID];
float c = cos(pc.right.w), s = sin(pc.right.w);
vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y);
vec3 world = pc.posSize.xyz + (pc.right.xyz * r.x + pc.up.xyz * r.y) * pc.posSize.w;
v_uv = q * 0.5 + 0.5;
v_uv.y = 1.0 - v_uv.y;
gl_Position = pc.viewProj * vec4(world, 1.0);
}

63
engine/shaders/ssao.frag Normal file
View file

@ -0,0 +1,63 @@
#version 460
// Ambient occlusion from the depth buffer alone (Alchemy-style, half resolution): for each
// pixel, view-space points around it in a spiral; those above the surface's plane and close
// enough darken it. Rows are the depth buffer's own (uv from gl_FragCoord), on both backends.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint depth; // scene depth slot
uint w, h; // its size
uint outW, outH; // this target's
float radius; // world units
float strength;
float pad;
vec4 proj; // tan(half fov x), tan(half fov y), near, far
} pc;
layout(location = 0) out vec4 o_ao;
// The point seen at uv: its depth from the pixel there, and its place across the screen from
// that same pixel's centre (not uv itself: on a slanted wall the two would disagree by a part of
// a pixel, in a pattern that depends on how the window's size halves; stripes on walls).
vec3 viewPos(vec2 uv) {
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
float d = fetchTex(pc.depth, p).r;
float z = pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
vec2 at = (vec2(p) + 0.5) / vec2(pc.w, pc.h);
return vec3((at * 2.0 - 1.0) * pc.proj.xy * z, z);
}
void main() {
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
ivec2 full = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
if (fetchTex(pc.depth, full).r >= 1.0) { o_ao = vec4(1.0); return; } // sky
vec3 p = viewPos(uv);
// The surface normal from neighbouring depths (the smaller step on each axis, so edges hold).
vec2 texel = 1.0 / vec2(pc.outW, pc.outH);
vec3 px1 = viewPos(uv + vec2(texel.x, 0)) - p, px2 = p - viewPos(uv - vec2(texel.x, 0));
vec3 py1 = viewPos(uv + vec2(0, texel.y)) - p, py2 = p - viewPos(uv - vec2(0, texel.y));
// The smaller step, but never one off the screen's edge (there it clamps onto this pixel,
// a zero step, and the normal goes wrong: a dark line along the border).
bool right = uv.x + texel.x < 1.0, left = uv.x - texel.x > 0.0, down = uv.y + texel.y < 1.0, up = uv.y - texel.y > 0.0;
vec3 dx = !right ? px2 : !left ? px1 : dot(px1, px1) < dot(px2, px2) ? px1 : px2;
vec3 dy = !down ? py2 : !up ? py1 : dot(py1, py1) < dot(py2, py2) ? py1 : py2;
vec3 n = normalize(cross(dx, dy));
if (dot(n, p) > 0.0) n = -n; // towards the camera
// Interleaved gradient noise turns the spiral per pixel; the blur evens it out.
float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
const int kSamples = 12;
float occlusion = 0.0;
for (int i = 0; i < kSamples; ++i) {
float t = (float(i) + noise) / float(kSamples);
float a = (float(i) * 2.39996 + noise * 6.2831853);
float r = pc.radius * t;
vec2 off = vec2(cos(a), sin(a)) * r / (p.z * pc.proj.xy * 2.0);
vec2 q = uv + off;
if (any(lessThan(q, vec2(0.0))) || any(greaterThan(q, vec2(1.0)))) continue; // off screen: nothing known there
vec3 v = viewPos(q) - p;
float vv = dot(v, v);
float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius));
occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + 1.0) * falloff;
}
float ao = max(0.0, 1.0 - 2.0 * occlusion / float(kSamples) * pc.radius * 0.6);
o_ao = vec4(mix(1.0, ao * ao, pc.strength));
}

View file

@ -0,0 +1,34 @@
#version 460
// Smooths the occlusion (4×4, weighted by how close each sample's depth is to this pixel's).
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint ao, depth;
uint w, h; // depth size
uint outW, outH; // this target's (= the occlusion's)
float pad0, pad1;
vec4 proj;
} pc;
layout(location = 0) out vec4 o_ao;
float linearDepth(vec2 uv) {
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
float d = fetchTex(pc.depth, p).r;
return pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
}
void main() {
ivec2 o = ivec2(gl_FragCoord.xy);
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
float z = linearDepth(uv);
float sum = 0.0, weight = 0.0;
for (int y = -2; y < 2; ++y)
for (int x = -2; x < 2; ++x) {
ivec2 q = clamp(o + ivec2(x, y), ivec2(0), ivec2(pc.outW - 1, pc.outH - 1));
float zq = linearDepth((vec2(q) + 0.5) / vec2(pc.outW, pc.outH));
float wgt = max(0.0, 1.0 - abs(zq - z) / (0.05 * z + 1.0));
sum += fetchTex(pc.ao, q).r * wgt;
weight += wgt;
}
o_ao = vec4(weight > 0.0 ? sum / weight : 1.0);
}

82
engine/shaders/ssr.frag Normal file
View file

@ -0,0 +1,82 @@
#version 460
// Screen-space reflections (half resolution): from each pixel, the view ray mirrored about its
// normal (the mesh pass's G-buffer) is marched through view space against the depth buffer;
// on a hit (refined by bisection) it takes this frame's lit scene there. Fades with roughness,
// distance, the screen's edges and rays towards the camera, so it blends into the sky and
// probe reflections the mesh pass already gave. TAA (or the tonemap) adds it to the scene.
// Rows are the targets' own (uv from gl_FragCoord), on both backends.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint depth, gbuf, scene;
uint w, h; // full size
uint outW, outH; // this target's
float strength;
vec4 proj; // tan(half fov x), tan(half fov y), near, far
} pc;
layout(location = 0) out vec4 o_ssr;
float viewZ(vec2 uv) {
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
float d = fetchTex(pc.depth, p).r;
return d >= 1.0 ? 1e9 : pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
}
vec3 viewPos(vec2 uv) { // across the screen from the centre of the pixel its depth came from (as ssao.frag)
float z = viewZ(uv);
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
vec2 at = (vec2(p) + 0.5) / vec2(pc.w, pc.h);
return vec3((at * 2.0 - 1.0) * pc.proj.xy * z, z);
}
vec2 toUv(vec3 v) { return v.xy / (v.z * pc.proj.xy) * 0.5 + 0.5; }
vec3 octDecode(vec2 e) {
e = e * 2.0 - 1.0;
vec3 n = vec3(e, 1.0 - abs(e.x) - abs(e.y));
if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);
return normalize(n);
}
void main() {
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
o_ssr = vec4(0.0);
vec4 g = fetchTex(pc.gbuf, clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)));
float rough = g.z, weight = g.w;
if (weight <= 0.01 || rough > 0.65) return;
vec3 p = viewPos(uv);
if (p.z > 1e8) return;
vec3 n = octDecode(g.xy);
vec3 v = normalize(p);
vec3 r = reflect(v, n);
if (r.z < -0.2) return; // back at the camera: nothing on screen to see there
// March: steps grow with distance; thickness grows with them.
float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
float t = 4.0 + 6.0 * jitter, stepLen = 6.0;
const int STEPS = 40;
vec2 hit = vec2(-1.0);
float hitT = 0.0;
for (int i = 0; i < STEPS; ++i) {
vec3 q = p + r * t;
if (q.z <= pc.proj.z) break;
vec2 quv = toUv(q);
if (any(lessThan(quv, vec2(0.0))) || any(greaterThan(quv, vec2(1.0)))) break;
float sceneZ = viewZ(quv);
float behind = q.z - sceneZ;
if (behind > 0.0 && behind < stepLen * 2.0 + 8.0) {
float lo = t - stepLen, hi = t; // refine between the last two steps
for (int k = 0; k < 5; ++k) {
float mid = (lo + hi) * 0.5;
vec3 m = p + r * mid;
if (m.z - viewZ(toUv(m)) > 0.0) hi = mid; else lo = mid;
}
hitT = hi;
hit = toUv(p + r * hi);
break;
}
t += stepLen;
stepLen *= 1.12;
}
if (hit.x < 0.0) return;
vec2 edge = smoothstep(vec2(0.0), vec2(0.12), hit) * smoothstep(vec2(0.0), vec2(0.12), 1.0 - hit);
float fade = edge.x * edge.y * (1.0 - smoothstep(0.3, 0.65, rough)) * (1.0 - smoothstep(1500.0, 3000.0, hitT)) * smoothstep(-0.2, 0.1, r.z + 0.1);
vec3 c = min(sampleTexLod(pc.scene, hit, 0.0).rgb, vec3(16.0));
o_ssr = vec4(c * weight * fade * pc.strength, fade);
}

69
engine/shaders/taa.frag Normal file
View file

@ -0,0 +1,69 @@
#version 460
// Temporal anti-aliasing (as id Tech 6's TSSAA, in spirit): the scene is drawn a sub-pixel off
// each frame; each pixel finds where it was last frame (its depth, last frame's camera) and
// blends with what was there, after clamping that to the colours around it now (variance
// clipping in YCoCg) so moved things don't leave ghosts. Weighted by brightness so highlights
// don't flicker. Rows are the targets' own (uv from gl_FragCoord), on both backends.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
#include "upsample.glsl"
PUSH_BLOCK {
mat4 reproject; // this frame's clip space → last frame's
uint scene, history, depth;
uint w, h;
uint reset; // no usable history (first frame, resized, teleported)
uint ssr; // reflections to add first (half size), 0 = none
float pad1;
} pc;
layout(location = 0) out vec4 o_color;
// The scene with the half-size reflections / volumetric light on it: depth-aware for the pixel
// itself (no lines along edges), a plain read for its neighbours (only the colour clamp uses them).
vec3 sceneAt(ivec2 q) {
vec3 c = fetchTex(pc.scene, q).rgb;
if (pc.ssr != 0u) c += sampleTexLod(pc.ssr, (vec2(q) + 0.5) / vec2(pc.w, pc.h), 0.0).rgb;
return c;
}
vec3 sceneAtCentre(ivec2 q) {
vec3 c = fetchTex(pc.scene, q).rgb;
if (pc.ssr != 0u) c += upsampleDepthAware(pc.ssr, pc.depth, q).rgb;
return c;
}
vec3 toYCoCg(vec3 c) { return vec3(dot(c, vec3(0.25, 0.5, 0.25)), dot(c, vec3(0.5, 0.0, -0.5)), dot(c, vec3(-0.25, 0.5, -0.25))); }
vec3 fromYCoCg(vec3 c) { return vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z); }
float weight(vec3 c) { return 1.0 / (1.0 + dot(c, vec3(0.2126, 0.7152, 0.0722))); }
void main() {
ivec2 p = ivec2(gl_FragCoord.xy);
ivec2 last = ivec2(pc.w - 1, pc.h - 1);
vec3 cur = sceneAtCentre(p).rgb;
if (pc.reset != 0u) { o_color = vec4(cur, 1.0); return; }
// The colours around this pixel now (mean ± deviation), and the nearest depth among them
// (so an edge's history comes from the thing in front).
vec3 m1 = vec3(0.0), m2 = vec3(0.0);
float nearest = 1.0;
ivec2 at = p;
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) {
ivec2 q = clamp(p + ivec2(x, y), ivec2(0), last);
vec3 c = toYCoCg(sceneAt(q).rgb);
m1 += c;
m2 += c * c;
float d = fetchTex(pc.depth, q).r;
if (d < nearest) nearest = d, at = q;
}
vec3 mu = m1 / 9.0, sigma = sqrt(abs(m2 / 9.0 - mu * mu));
vec3 lo = mu - 1.25 * sigma, hi = mu + 1.25 * sigma;
vec2 uv = (vec2(p) + 0.5) / vec2(pc.w, pc.h);
vec4 prev = pc.reproject * vec4(((vec2(at) + 0.5) / vec2(pc.w, pc.h)) * 2.0 - 1.0, nearest, 1.0);
vec2 puv = prev.xy / prev.w * 0.5 + 0.5 + (uv - (vec2(at) + 0.5) / vec2(pc.w, pc.h));
if (any(lessThan(puv, vec2(0.0))) || any(greaterThan(puv, vec2(1.0)))) { o_color = vec4(cur, 1.0); return; }
vec3 hist = sampleTex(pc.history, puv).rgb;
hist = fromYCoCg(clamp(toYCoCg(hist), lo, hi));
// Less history when things move a lot on screen (it smears), more when still.
float moved = length((puv - uv) * vec2(pc.w, pc.h));
float keep = mix(0.92, 0.7, clamp(moved / 24.0, 0.0, 1.0));
float wc = (1.0 - keep) * weight(cur), wh = keep * weight(hist);
o_color = vec4((cur * wc + hist * wh) / (wc + wh), 1.0);
}

View file

@ -0,0 +1,78 @@
#version 460
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
#include "upsample.glsl"
// HDR scene → display: ambient occlusion, bloom, exposure, ACES filmic curve, a light grade
// (saturation, vignette), then sRGB encoding.
PUSH_BLOCK {
uint scene;
float exposure;
uint ao; // 0 = none
uint bloom; // 0 = none
float bloomStrength;
float saturation;
float vignette;
float sharpen; // after TAA: give back a little crispness (0 = none)
uint depth; // scene depth (1 = sky)
uint ssr; // reflections to add (when TAA didn't), 0 = none
uint adapt; // eye adaptation (1×1 exposure multiplier), 0 = none
uint pad2;
vec4 shafts; // sun shafts: the sun's uv, strength (0 = none)
vec4 sunColor;
} pc;
// Sun shafts (screen-space light scattering, GPU Gems 3 ch. 13): march towards the sun's spot
// on screen, adding up how much open sky lies on the way; air lit by the sun glows there,
// fading with distance from the sun. Started at a per-pixel offset so the steps don't band.
vec3 sunShafts(vec2 uv) {
const int N = 32;
vec2 toSun = pc.shafts.xy - uv;
vec2 stepUv = toSun * (0.85 / float(N));
float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
vec2 p = uv + stepUv * jitter;
vec3 lit = vec3(0.0);
float w = 1.0, total = 0.0;
for (int i = 0; i < N; ++i, p += stepUv) {
bool inside = all(greaterThanEqual(p, vec2(0.0))) && all(lessThanEqual(p, vec2(1.0)));
if (inside && sampleTexLod(pc.depth, p, 0.0).r >= 1.0) // open sky: its light, the sun's brightest
lit += max(sampleTexLod(pc.scene, p, 0.0).rgb - 1.0, vec3(0.0)) * w;
total += w;
w *= 0.97;
}
vec2 aspect = vec2(float(texSize(pc.scene).x) / float(texSize(pc.scene).y), 1.0);
float fall = exp(-length(toSun * aspect) * 1.5);
return min(lit / total, vec3(8.0)) * fall * pc.shafts.z;
}
layout(location = 0) in vec2 v_uv;
layout(location = 0) out vec4 o_color;
vec3 aces(vec3 x) {
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}
vec3 toSrgb(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
}
void main() {
vec3 hdr = sampleTex(pc.scene, v_uv).rgb;
const ivec2 px = ivec2(v_uv * vec2(texSize(pc.depth)));
if (pc.ssr != 0u) hdr += upsampleDepthAware(pc.ssr, pc.depth, px).rgb;
if (pc.sharpen > 0.0) { // unsharp mask against the 4 neighbours, limited so edges don't ring
vec2 t = 1.0 / vec2(texSize(pc.scene));
vec3 n = sampleTex(pc.scene, v_uv + vec2(t.x, 0)).rgb + sampleTex(pc.scene, v_uv - vec2(t.x, 0)).rgb
+ sampleTex(pc.scene, v_uv + vec2(0, t.y)).rgb + sampleTex(pc.scene, v_uv - vec2(0, t.y)).rgb;
hdr = max(hdr + clamp((hdr - n * 0.25) * pc.sharpen, -0.1 * (hdr + 0.05), 0.1 * (hdr + 0.05)), 0.0);
}
if (pc.ao != 0u) hdr *= upsampleDepthAware(pc.ao, pc.depth, px).r;
if (pc.bloom != 0u) hdr = mix(hdr, sampleTex(pc.bloom, v_uv).rgb, pc.bloomStrength);
if (pc.shafts.z > 0.0) hdr += sunShafts(v_uv);
float exposure = pc.exposure * (pc.adapt != 0u ? sampleTexLod(pc.adapt, vec2(0.5), 0.0).r : 1.0);
vec3 c = aces(hdr * exposure);
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
c = max(mix(vec3(l), c, pc.saturation), 0.0);
vec2 d = v_uv - 0.5;
c *= 1.0 - pc.vignette * dot(d, d) * 2.0;
o_color = vec4(toSrgb(c), 1.0);
}

View file

@ -0,0 +1,28 @@
// Depth-aware ("bilateral") upsampling of a half-size target (ambient occlusion, reflections,
// volumetric light): the four nearest texels, each weighted by how close its depth is to this
// pixel's, so what's behind an edge doesn't bleed onto it (a plain bilinear read leaves dark or
// bright lines along corners and silhouettes).
float linearDepth(float d) {
const float n = 2.0, f = 16384.0; // the camera's near and far planes (render/renderer.cpp)
return d >= 1.0 ? f : n * f / (f - d * (f - n));
}
vec4 upsampleDepthAware(uint tex, uint depthTex, ivec2 px) {
ivec2 full = texSize(depthTex), half_ = texSize(tex);
vec2 hp = (vec2(px) + 0.5) * vec2(half_) / vec2(full) - 0.5;
ivec2 b = ivec2(floor(hp));
vec2 f = hp - vec2(b);
float z = linearDepth(fetchTex(depthTex, clamp(px, ivec2(0), full - 1)).r);
vec4 sum = vec4(0.0);
float wsum = 0.0;
for (int k = 0; k < 4; ++k) {
ivec2 o = ivec2(k & 1, k >> 1);
ivec2 t = clamp(b + o, ivec2(0), half_ - 1);
ivec2 tf = clamp((t * full) / half_ + (full / half_) / 2, ivec2(0), full - 1); // the full-size texel it stands for
float zt = linearDepth(fetchTex(depthTex, tf).r);
float bil = (o.x == 1 ? f.x : 1.0 - f.x) * (o.y == 1 ? f.y : 1.0 - f.y);
float w = bil / (0.002 + abs(zt - z) / max(z, 1.0));
sum += fetchTex(tex, t) * w;
wsum += w;
}
return wsum > 0.0 ? sum / wsum : fetchTex(tex, clamp(b, ivec2(0), half_ - 1));
}

View file

@ -0,0 +1,56 @@
#version 460
// Volumetric light (half resolution): the sun and the dynamic lights scattered towards the eye
// by the air along each view ray, where they reach it (12 jittered steps between the eye and the
// depth buffer; each takes the sun's shadow and, for every light in that spot's cluster, its
// falloff, cone and a one-tap shadow; Henyey-Greenstein phase), so window frames cut shafts out of
// the sun and lamps glow in the haze (like Source 2's volumetric fog). Once per pixel, whatever overdraw the scene had. Added onto the reflections
// target (TAA, or the tonemap, adds that to the scene). Rows: the targets' own, both backends.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
PUSH_BLOCK {
#ifndef KEEL_GL
FrameData frame;
#endif
uint depth;
uint w, h, outW, outH;
float pad0, pad1, pad2;
vec4 proj; // tan(half fov x), tan(half fov y), near, far
} pc;
#ifndef KEEL_GL
#define FRAME pc.frame
#endif
layout(location = 0) out vec4 o_light;
#include "airlight.glsl"
void main() {
o_light = vec4(0.0);
if (FRAME.volumetric.x <= 0.0) return;
const bool sun = FRAME.sunColor.w > 0.5 && FRAME.volumetric.w > 0.5;
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
ivec2 px = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
float d = fetchTex(pc.depth, px).r;
float z = d >= 1.0 ? 1e9 : pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
// The view ray in world space (view: x right, y down the screen, z forward).
vec3 fwd = FRAME.camFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec2 s = (uv * 2.0 - 1.0) * pc.proj.xy;
vec3 rd = normalize(fwd + right * s.x - up * s.y);
float dist = z * length(vec3(s, 1.0));
float len = min(dist, FRAME.volumetric.y);
float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
float lit = 0.0;
vec3 lamps = vec3(0.0);
const int N = 12;
for (int i = 0; i < N; ++i) {
vec3 w = FRAME.camPos.xyz + rd * len * ((float(i) + jitter) / float(N));
if (sun) lit += sunlitAt(w);
lamps += lightsAt(w, rd, uv);
}
float scattered = 1.0 - exp(-len * FRAME.volumetric.z);
vec3 sunPart = sun ? FRAME.sunColor.rgb * (lit / float(N)) * phaseHG(dot(rd, FRAME.sunDir.xyz), 0.6) : vec3(0.0);
// Lamps: each step's light over the stretch it stands for (the air between them is lit too).
vec3 lampPart = lamps / float(N) * scattered * 7.0;
o_light = vec4((sunPart * scattered + lampPart) * FRAME.volumetric.x, 0.0);
}