Skip to content
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
# Unreleased

## New Features

- `OffscreenSurface` renders with MSAA: `OffscreenSurface::set_samples` / `samples`, or
`CanvasSetup::samples` through `with_setup`. `OffscreenSurface::new` stays single-sampled.
Offscreen frames used to ignore the canvas sample count and always render one sample.
- `OffscreenSurface::set_shadow_softness` / `shadow_softness` and `set_shadows_enabled` /
`shadows_enabled`, mirroring the `Window` methods.
- `ShadowMapper::set_atlas_layers` (`Window` / `OffscreenSurface::set_shadow_atlas_layers`)
allocates fewer than the 16 shadow atlas layers, which makes a finer shadow resolution
affordable when a scene only lights with one or two directional lights.
- `TextureManager::set_anisotropy`: anisotropic filtering (up to 16x) for textures loaded with
mipmaps and linear filtering, which keeps floors and other grazing-angle textures sharp.
- `Window` and `OffscreenSurface` expose the directional cascade layout that only the
`ShadowMapper` had: `set_shadow_distance` / `shadow_distance` and
`set_first_cascade_far_bound` / `first_cascade_far_bound`.
- Added the `offscreen_msaa` example: an antialiased, hard-shadowed offscreen render.

## Bug Fixes

- Creating a second `Window` or `OffscreenSurface` on a thread that already has one no longer replaces the global mesh, texture and material managers. Objects created before the new surface kept the previous default material, which the per-frame `begin_frame` / `flush` no longer reached, so its per-object uniform buffer grew until wgpu rejected the dynamic offsets (a validation error after 256 draws). The managers are now created once per context and reused until the last window closes.
- Added the `offscreen_shared_managers` example exercising a scene built before its offscreen surface, with shaded renders interleaved with depth and segmentation read-backs.

# v0.46.0

## Breaking Changes
Expand Down
35 changes: 35 additions & 0 deletions examples/offscreen_msaa.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
use kiss3d::prelude::*;
use kiss3d::window::NumSamples;
use std::path::Path;

// Renders a scene to an image with 4x MSAA and hard shadow edges, no window.
#[kiss3d::main]
async fn main() {
let mut surface = OffscreenSurface::new(1024, 768).await;
surface.set_background_color(DARK_BLUE);
surface.set_samples(NumSamples::Four);
surface.set_shadow_softness(0.0);

let mut camera = OrbitCamera3d::default();
let mut scene = SceneNode3d::empty();
scene
.add_light(Light::point(100.0))
.set_position(Vec3::new(2.0, 10.0, 4.0));
scene
.add_cube(4.0, 0.05, 4.0)
.set_color(Color::new(0.3, 0.33, 0.38, 1.0))
.set_position(Vec3::new(0.0, -0.3, 0.0));
scene
.add_cube(0.2, 0.2, 0.2)
.set_color(RED)
.rotate(Quat::from_axis_angle(Vec3::Y, 0.785))
.rotate(Quat::from_axis_angle(Vec3::X, -0.6f32));

let img = surface.render_image_3d(&mut scene, &mut camera).await;
img.save(Path::new("offscreen_msaa.png")).unwrap();
println!(
"Rendered to `offscreen_msaa.png` ({:?}, {} samples)",
surface.size(),
surface.samples()
);
}
41 changes: 41 additions & 0 deletions examples/offscreen_shared_managers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Regression check: a scene built against the main window's resource
//! managers, then rendered through an offscreen surface created afterwards,
//! with shaded renders interleaved with AOV read-backs on more objects than
//! the per-object uniform buffer's initial capacity. Creating the surface
//! used to replace the global managers, orphaning the objects' material so
//! its uniform buffer was never cleared or flushed again.

use kiss3d::prelude::*;

#[kiss3d::main]
async fn main() {
// Like a viewer that owns a headless main window, builds its scene, and
// only then opens offscreen sensor surfaces sharing the context: the
// objects must keep working with the resource managers they were built
// against.
let _main = Window::new_headless_with_setup(64, 64, CanvasSetup::default()).await;
let mut camera = OrbitCamera3d::new(Vec3::new(0.0, 6.0, 12.0), Vec3::ZERO);
let mut scene = SceneNode3d::empty();
scene.add_directional_light(Vec3::new(0.3, -0.4, -1.0));
// 100 objects: four unflushed prepares exceed the 256-entry default.
for i in 0..100 {
let mut cube = scene.add_cube(0.3, 0.3, 0.3);
let x = (i % 10) as f32 - 4.5;
let z = (i / 10) as f32 - 4.5;
cube.set_position(Vec3::new(x, 0.0, z));
cube.apply_to_object_mut(&mut |o| o.set_segmentation_id(i as u32 + 1));
}
let mut surface = OffscreenSurface::new(320, 240).await;
for round in 0..4 {
surface.render_3d(&mut scene, &mut camera).await;
let _ = surface.snap_image();
let depth = surface.snap_depth_raw(&mut scene, &mut camera);
surface.render_3d(&mut scene, &mut camera).await;
let ids = surface.snap_segmentation(&mut scene, &mut camera);
surface.render_3d(&mut scene, &mut camera).await;
let hit = depth.iter().filter(|d| **d > 0.0).count();
let max_id = ids.iter().copied().max().unwrap_or(0);
println!("round {round}: depth hits {hit}, max id {max_id}");
}
println!("ok");
}
1 change: 1 addition & 0 deletions examples/parallax.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ fn load(data: &[u8], srgb: bool, invert: bool, flip_green: bool) -> Arc<Texture>
wgpu::AddressMode::ClampToEdge,
wgpu::FilterMode::Linear,
true,
1,
)
}

Expand Down
58 changes: 47 additions & 11 deletions src/builtin/shadow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ pub struct ShadowMapper {
enabled: bool,
/// Atlas resolution (per layer, square).
resolution: u32,
/// Atlas layers allocated (`1..=MAX_SHADOW_VIEWS`); lights whose views do not
/// fit keep lighting without shadows.
atlas_layers: u32,
/// Slope-scaled-ish constant depth bias used by the PCF comparison.
depth_bias: f32,
/// Shadow-edge softness: scales the PCF tap spacing (`1.0` = default ~5x5
Expand Down Expand Up @@ -307,10 +310,11 @@ impl ShadowMapper {
pub fn new(resolution: u32) -> Self {
let ctxt = Context::get();
let resolution = resolution.max(1);
let atlas_layers = MAX_SHADOW_VIEWS as u32;

let (atlas, layer_views, array_view) = Self::create_atlas(&ctxt, resolution);
let (atlas, layer_views, array_view) = Self::create_atlas(&ctxt, resolution, atlas_layers);
let (transmittance_atlas, transmittance_layer_views, transmittance_array_view) =
Self::create_transmittance_atlas(&ctxt, resolution);
Self::create_transmittance_atlas(&ctxt, resolution, atlas_layers);

// Comparison sampler: hardware does the depth test and (with linear
// filtering) bilinear PCF across the 2x2 neighborhood per tap.
Expand Down Expand Up @@ -510,6 +514,7 @@ impl ShadowMapper {
Self {
enabled: true,
resolution,
atlas_layers,
depth_bias: 0.0012,
softness: 1.0,
num_cascades: 4,
Expand Down Expand Up @@ -667,13 +672,14 @@ impl ShadowMapper {
fn create_atlas(
ctxt: &Context,
resolution: u32,
layers: u32,
) -> (wgpu::Texture, Vec<wgpu::TextureView>, wgpu::TextureView) {
let atlas = ctxt.create_texture(&wgpu::TextureDescriptor {
label: Some("shadow_atlas"),
size: wgpu::Extent3d {
width: resolution,
height: resolution,
depth_or_array_layers: MAX_SHADOW_VIEWS as u32,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
Expand All @@ -683,7 +689,7 @@ impl ShadowMapper {
view_formats: &[],
});

let layer_views = (0..MAX_SHADOW_VIEWS as u32)
let layer_views = (0..layers)
.map(|layer| {
atlas.create_view(&wgpu::TextureViewDescriptor {
label: Some("shadow_atlas_layer"),
Expand All @@ -710,13 +716,14 @@ impl ShadowMapper {
fn create_transmittance_atlas(
ctxt: &Context,
resolution: u32,
layers: u32,
) -> (wgpu::Texture, Vec<wgpu::TextureView>, wgpu::TextureView) {
let atlas = ctxt.create_texture(&wgpu::TextureDescriptor {
label: Some("shadow_transmittance_atlas"),
size: wgpu::Extent3d {
width: resolution,
height: resolution,
depth_or_array_layers: MAX_SHADOW_VIEWS as u32,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
Expand All @@ -726,7 +733,7 @@ impl ShadowMapper {
view_formats: &[],
});

let layer_views = (0..MAX_SHADOW_VIEWS as u32)
let layer_views = (0..layers)
.map(|layer| {
atlas.create_view(&wgpu::TextureViewDescriptor {
label: Some("shadow_transmittance_layer"),
Expand Down Expand Up @@ -1375,20 +1382,49 @@ impl ShadowMapper {
self.first_cascade_far_bound = bound.max(0.01);
}

/// Far view distance of the highest-resolution directional cascade.
pub fn first_cascade_far_bound(&self) -> f32 {
self.first_cascade_far_bound
}

/// Sets the shadow atlas per-layer resolution, reallocating the atlas.
pub fn set_resolution(&mut self, resolution: u32) {
let resolution = resolution.max(1);
if resolution == self.resolution {
return;
}
let ctxt = Context::get();
self.resolution = resolution;
let (atlas, layer_views, array_view) = Self::create_atlas(&ctxt, resolution);
self.reallocate_atlases();
}

/// Number of atlas layers allocated (the default is [`MAX_SHADOW_VIEWS`]).
pub fn atlas_layers(&self) -> u32 {
self.atlas_layers
}

/// Sets how many atlas layers are allocated (`1..=MAX_SHADOW_VIEWS`),
/// reallocating the atlas. A directional light needs one layer per cascade,
/// a spot light one, a point light six; lights that do not fit keep lighting
/// without shadows. Fewer layers make a higher `resolution` affordable: the
/// atlas costs `resolution² × layers × 8` bytes (depth plus transmittance).
pub fn set_atlas_layers(&mut self, layers: u32) {
let layers = layers.clamp(1, MAX_SHADOW_VIEWS as u32);
if layers == self.atlas_layers {
return;
}
self.atlas_layers = layers;
self.reallocate_atlases();
}

fn reallocate_atlases(&mut self) {
let ctxt = Context::get();
let (atlas, layer_views, array_view) =
Self::create_atlas(&ctxt, self.resolution, self.atlas_layers);
self.atlas = atlas;
self.layer_views = layer_views;
self.array_view = array_view;
let (t_atlas, t_layer_views, t_array_view) =
Self::create_transmittance_atlas(&ctxt, resolution);
Self::create_transmittance_atlas(&ctxt, self.resolution, self.atlas_layers);
self.transmittance_atlas = t_atlas;
self.transmittance_layer_views = t_layer_views;
self.transmittance_array_view = t_array_view;
Expand Down Expand Up @@ -1490,7 +1526,7 @@ impl ShadowMapper {
continue;
}
let needed = self.shadow_view_count(light);
if next_layer as usize + needed > MAX_SHADOW_VIEWS {
if next_layer + needed as u32 > self.atlas_layers {
// Out of atlas space: this light lights without shadows.
continue;
}
Expand Down Expand Up @@ -1518,7 +1554,7 @@ impl ShadowMapper {
continue;
}
let needed = self.shadow_view_count(light);
if next_layer as usize + needed > MAX_SHADOW_VIEWS {
if next_layer + needed as u32 > self.atlas_layers {
// Out of atlas space; a smaller later light might still fit.
continue;
}
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/point_renderer2d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ impl PointRenderer2d {
!self.points.is_empty()
}

/// Drops the points queued for the next frame.
pub(crate) fn clear(&mut self) {
self.points.clear();
}

/// Adds a 2D point to be drawn during the next frame. Points are not persistent between frames.
/// This method must be called for each point to draw, and at each update loop iteration.
pub fn draw_point(&mut self, pt: Vec2, color: Color, size: f32) {
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/point_renderer3d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ impl PointRenderer3d {
!self.points.is_empty()
}

/// Drops the points queued for the next frame.
pub(crate) fn clear(&mut self) {
self.points.clear();
}

/// Adds a point to be drawn during the next frame. Points are not persistent between frames.
/// This method must be called for each point to draw, and at each update loop iteration.
pub fn draw_point(&mut self, pt: Vec3, color: Color, size: f32) {
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/polyline_renderer2d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,11 @@ impl PolylineRenderer2d {
!self.segments.is_empty()
}

/// Drops the segments queued for the next frame.
pub(crate) fn clear(&mut self) {
self.segments.clear();
}

/// Adds a 2D polyline to be drawn during the next frame.
/// Takes a reference to avoid allocations - segments are built immediately.
/// Polylines are not persistent between frames.
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/polyline_renderer3d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,11 @@ impl PolylineRenderer3d {
!self.segments.is_empty()
}

/// Drops the segments queued for the next frame.
pub(crate) fn clear(&mut self) {
self.segments.clear();
}

/// Adds a polyline to be drawn during the next frame.
/// Takes a reference to avoid allocations - segments are built immediately.
/// Polylines are not persistent between frames.
Expand Down
Loading
Loading