Shader API: translucent terrain in the shadow pass, and its colour

Translucent terrain is drawn over a copy of the solid shadow map, so a pack can tell a point under
water or stained glass from one in shadow: shadow_map_translucent holds that depth, and shadow_color
the texture colour times the tint of the nearest such surface, with alpha 1 on a fluid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
This commit is contained in:
TeiwazikandClaude Opus 5.5 committed 2026-10-31 21:32:52 +01:00
1 parent acc8c21f4c
commit d36550e82c
6 files changed
+237 -36

No files matched your search

+28
View File
@@ -5597,6 +5597,34 @@ impl Renderer {
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(&mut pass, &self.indirect, 0, shadow_first, shadow_solid);
}
drop(pass);
let mut pass = native
.frame
.shadow_map
.begin_translucent(&mut encoder, "Native shader pack translucent shadow");
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat_translucent
{
pass.set_pipeline(pipeline);
let [_, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
self.arena.buffer(p)
});
} else if !flat_frame {
pass.set_pipeline(&shadow.translucent);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
&mut pass,
&self.indirect,
0,
shadow_first + shadow_solid,
shadow_translucent,
);
}
}
if native_sky_pass
&& let Some(native) = &mut self.native
@@ -24,8 +24,14 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// The frame's shadow map and its comparison sampler (bindings 6 and 7).
shadow: (wgpu::TextureView, wgpu::Sampler),
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
/// and its colour (bindings 9 and 10).
shadow: (
wgpu::TextureView,
wgpu::Sampler,
wgpu::TextureView,
wgpu::TextureView,
),
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
@@ -165,6 +171,8 @@ impl NativeFrame {
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
});
@@ -247,6 +255,14 @@ impl NativeFrame {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
@@ -449,6 +465,8 @@ impl NativePost {
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -516,6 +534,8 @@ impl NativePost {
shadow: (
frame.shadow_map.view.clone(),
frame.shadow_map.sampler.clone(),
frame.shadow_map.translucent_view.clone(),
frame.shadow_map.color_view.clone(),
),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
@@ -638,6 +658,14 @@ impl NativePost {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
],
})
})
@@ -1,6 +1,7 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain the light (the sun by day, the moon by night) sees is drawn into a square depth
//! map, which the material and post stages read as `shadow_map`. The shadow camera is the one
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
@@ -21,8 +22,13 @@ struct HorizonShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
}
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32) -> HorizonShadowOut {
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
-> HorizonShadowOut {
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
let k = frame.shadow_distortion;
let xy = clip.xy / (length(clip.xy) * k + 1.0 - k);
@@ -30,27 +36,42 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32) -> HorizonSh
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
return out;
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer);
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer);
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
@@ -89,9 +110,14 @@ pub fn distortion(distance: f32, resolution: u32) -> f32 {
}
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
/// so everything is lit) for a pack without `[shadow]`.
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
/// in, and that terrain's colour.
pub struct ShadowMap {
depth: wgpu::Texture,
pub view: wgpu::TextureView,
translucent: wgpu::Texture,
pub translucent_view: wgpu::TextureView,
pub color_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
/// The 1×1 map still holds the zeroes it was made with.
blank_uncleared: bool,
@@ -102,20 +128,39 @@ impl ShadowMap {
let size = settings
.map_or(1, |s| s.resolution)
.clamp(1, device.limits().max_texture_dimension_2d);
let depth = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack shadow map"),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| usage,
view_formats: &[],
})
};
let depth = texture(
"Native shader pack shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_SRC,
);
let translucent = texture(
"Native shader pack translucent shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_DST,
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
@@ -125,6 +170,10 @@ impl ShadowMap {
});
Self {
view: depth.create_view(&Default::default()),
depth,
translucent_view: translucent.create_view(&Default::default()),
translucent,
color_view: color.create_view(&Default::default()),
sampler,
blank_uncleared: settings.is_none(),
}
@@ -151,10 +200,46 @@ impl ShadowMap {
})
}
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
pub fn begin_translucent<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
self.translucent.as_image_copy(),
self.depth.size(),
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.translucent_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
}
}
}
@@ -166,6 +251,10 @@ pub struct NativeShadow {
pub camera: Shadow,
pub solid: wgpu::RenderPipeline,
pub flat: Option<wgpu::RenderPipeline>,
/// The translucent terrain's draws into the translucent map and the colour, instanced and
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
pub group: wgpu::BindGroup,
}
@@ -245,9 +334,15 @@ impl NativeShadow {
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
@@ -281,26 +376,44 @@ impl NativeShadow {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_shadow_fs"),
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
compilation_options: options,
targets: &[],
targets: if translucent { &color_target } else { &[] },
}),
multiview_mask: None,
cache: None,
})
};
let instanced = layout(None);
let solid = pipeline(
&layout(None),
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances.clone())],
false,
);
let translucent = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances)],
true,
);
let flat = shape.flat.map(|group| {
pipeline(
&layout(Some(group)),
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
)
});
let flat_layout = shape.flat.map(|group| layout(Some(group)));
let flat_pipeline = |translucent| {
flat_layout.as_ref().map(|layout| {
pipeline(
layout,
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
translucent,
)
})
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
@@ -308,6 +421,8 @@ impl NativeShadow {
camera: camera(settings),
solid,
flat,
translucent,
flat_translucent,
group,
})
}
+26 -2
View File
@@ -54,6 +54,26 @@ const fn shadow_sampler(binding: u32) -> Resource {
}
}
/// The shadow map with translucent terrain at `binding`.
const fn shadow_map_translucent(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_map_translucent",
ty: "var shadow_map_translucent: texture_depth_2d",
doc: "`shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of.",
}
}
/// The colour of the translucent terrain in the shadow map at `binding`.
const fn shadow_color(binding: u32) -> Resource {
Resource {
binding,
name: "shadow_color",
ty: "var shadow_color: texture_2d<f32>",
doc: "The texture colour times the tint (rgb) of the nearest translucent surface the light passes through at each texel of `shadow_map_translucent`, white where there is none; alpha is 1 on a fluid (water) and the texture's alpha, at most 0.99, on anything else. Read it with `textureLoad` at `shadow_position`'s xy times `textureDimensions(shadow_color)`.",
}
}
/// A function the material and post preludes give packs.
const SHADOW_HELPERS: &str = "\
// Where the camera-relative point `position` is stored in the shadow map: xy its 0..1 texture
@@ -66,7 +86,7 @@ fn shadow_position(position: vec3<f32>) -> vec3<f32> {
}
";
const POST_RESOURCES: [Resource; 9] = [
const POST_RESOURCES: [Resource; 11] = [
FRAME,
Resource {
binding: 1,
@@ -106,11 +126,13 @@ const POST_RESOURCES: [Resource; 9] = [
ty: "var history: texture_2d<f32>",
doc: "Last frame's input to the last pass (what the pass before it wrote), for temporal passes (TAA, denoising): put such a pass second to last and it reads its own previous output; reproject with `frame.previous_view_projection`. Black on the first frame, after a gap of over half a second, and with a single pass.",
},
shadow_map_translucent(9),
shadow_color(10),
];
/// The material stage's resources: the frame, and the world as it was before translucent
/// terrain drew (for water and glass to see what lies behind them).
const MATERIAL_RESOURCES: [Resource; 7] = [
const MATERIAL_RESOURCES: [Resource; 9] = [
FRAME,
Resource {
binding: 1,
@@ -126,6 +148,8 @@ const MATERIAL_RESOURCES: [Resource; 7] = [
},
shadow_map(3),
shadow_sampler(4),
shadow_map_translucent(7),
shadow_color(8),
Resource {
binding: 6,
name: "opaque_depth",
+4
View File
@@ -76,6 +76,8 @@ Resources (bind group 3):
| 2 | `var reflection_sampler: sampler` | Bilinear filtering, clamped to the edges. |
| 3 | `var shadow_map: texture_depth_2d` | This frame's shadow map: the depth of the solid terrain the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything. |
| 4 | `var shadow_sampler: sampler_comparison` | Depth comparison, bilinear (2×2 percentage-closer filtering): `textureSampleCompareLevel(shadow_map, shadow_sampler, p.xy, p.z)` is 1 where the point is lit, 0 in shadow. |
| 7 | `var shadow_map_translucent: texture_depth_2d` | `shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of. |
| 8 | `var shadow_color: texture_2d<f32>` | The texture colour times the tint (rgb) of the nearest translucent surface the light passes through at each texel of `shadow_map_translucent`, white where there is none; alpha is 1 on a fluid (water) and the texture's alpha, at most 0.99, on anything else. Read it with `textureLoad` at `shadow_position`'s xy times `textureDimensions(shadow_color)`. |
| 6 | `var opaque_depth: texture_depth_2d` | The world's depth before translucent terrain (reversed, like `scene_depth`), for the translucent pass: `textureLoad(opaque_depth, vec2<i32>(input.screen * frame.resolution), 0)` is what lies behind water or glass. Stale while solid terrain draws. |
| 11 | `var opaque_color: texture_2d<f32>` | The world's picture before translucent terrain (sky, solid terrain, entities), the size of `opaque_depth`, for the translucent pass: what a surface refracts. Read it with `textureLoad`. Stale while solid terrain draws; black when the world draws straight to the window (a pack without `[[post]]` passes, at full resolution). |
@@ -107,6 +109,8 @@ Resources (bind group 3):
| 6 | `var shadow_map: texture_depth_2d` | This frame's shadow map: the depth of the solid terrain the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything. |
| 7 | `var shadow_sampler: sampler_comparison` | Depth comparison, bilinear (2×2 percentage-closer filtering): `textureSampleCompareLevel(shadow_map, shadow_sampler, p.xy, p.z)` is 1 where the point is lit, 0 in shadow. |
| 8 | `var history: texture_2d<f32>` | Last frame's input to the last pass (what the pass before it wrote), for temporal passes (TAA, denoising): put such a pass second to last and it reads its own previous output; reproject with `frame.previous_view_projection`. Black on the first frame, after a gap of over half a second, and with a single pass. |
| 9 | `var shadow_map_translucent: texture_depth_2d` | `shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of. |
| 10 | `var shadow_color: texture_2d<f32>` | The texture colour times the tint (rgb) of the nearest translucent surface the light passes through at each texel of `shadow_map_translucent`, white where there is none; alpha is 1 on a fluid (water) and the texture's alpha, at most 0.99, on anything else. Read it with `textureLoad` at `shadow_position`'s xy times `textureDimensions(shadow_color)`. |
Helpers:
+2
View File
@@ -107,6 +107,8 @@ stages.
## Changelog
- 2026-10-31: API 0.1 (additions): translucent terrain in the shadow pass: `shadow_map_translucent`
and `shadow_color`, so a pack can tint the light that passes through water and stained glass.
- 2026-10-31: API 0.1 (additions): `[shadow]`: the engine draws the solid terrain the light sees
into a distorted depth map each frame, which the material and post stages read as `shadow_map`
with `shadow_sampler` and the prelude's `shadow_position`; the frame's `light_direction`,