fix: make the renderer actually draw (step size, storage buffer, quad fit)
The committed Phase 1 code rendered a black screen due to three independent bugs, fixed here: - black_hole.wgsl: dt was sized as |eye|/steps, so the full integration only traveled |eye| units total — rays never reached capture or escape and every pixel fell through to accum=black. Now dt covers eye_dist + escape_r across the configured step count, and the escape test uses the dynamic escape_r instead of a hardcoded 1000.0. - render/plugin.rs: the planets binding was Handle::default(), causing AsBindGroup to return RetryNextUpdate every frame and silently skip the fullscreen quad's draw. Pre-fill a MAX_PLANETS-sized zeroed ShaderBuffer at startup so the binding resolves immediately. - render/plugin.rs: scale the fullscreen quad by half the window size to match Camera2d's default WindowSize projection (1 unit = 1 px); the old aspect-based scaling letterboxed the image. - render/plugin.rs: add nudge_camera to work around Bevy 0.19 #24448, where a static camera stops rendering after the first frame. - planets.wgsl: rotate world-space planet centers into disk-local space before the ray-sphere test (ray and centers were in different spaces).
This commit is contained in:
parent
87b089eb35
commit
ee15ddecb7
@ -50,7 +50,15 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
var pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt);
|
||||
var d = normalize(rot_x(dir, -uniforms.disk_tilt));
|
||||
|
||||
let dt = max(length(uniforms.eye.xyz), 20.0) / f32(uniforms.steps);
|
||||
// Total path length to integrate: enough to go from the camera, past the
|
||||
// hole, and far enough beyond to count as escaped. We size dt so that
|
||||
// `steps` steps cover this distance. (The original dt=|eye|/steps only
|
||||
// traveled |eye| units total — never reaching capture or escape — so
|
||||
// every ray fell through to accum=black.)
|
||||
let eye_dist = length(uniforms.eye.xyz);
|
||||
let escape_r = max(eye_dist * 2.0, 100.0); // "escaped" = clearly past the hole
|
||||
let total_path = eye_dist + escape_r; // go in, through, and out
|
||||
let dt = total_path / f32(uniforms.steps);
|
||||
let steps = uniforms.steps;
|
||||
|
||||
// Front-to-back compositing.
|
||||
@ -64,7 +72,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Captured: whatever we've composited so far is the result.
|
||||
break;
|
||||
}
|
||||
if (r > 1000.0) {
|
||||
if (r > escape_r) {
|
||||
// Escaped: add background along the (disk-local) final dir.
|
||||
// Rotate back to world for the sky/stars sample.
|
||||
let world_dir = normalize(rot_x(d, uniforms.disk_tilt));
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
#define_import_path singularity::planets
|
||||
|
||||
#import singularity::disk::rot_x
|
||||
|
||||
struct SphereData {
|
||||
center: vec4<f32>, // xyz = center, w = radius
|
||||
center: vec4<f32>, // xyz = center (world space), w = radius
|
||||
color: vec4<f32>, // xyz = color, w = emissive flag (u32 reinterpreted; we just check > 0.5)
|
||||
};
|
||||
|
||||
@ -11,13 +13,18 @@ struct SphereData {
|
||||
|
||||
// Test the segment prev->cur against all planets. Returns hit color & alpha,
|
||||
// or (0,0,0,0) if no hit. `dir` is the ray direction (for shading).
|
||||
// `prev`/`cur` are in DISK-LOCAL space (the caller rotates eye/dir by -disk_tilt
|
||||
// before integrating), so we rotate each planet's world-space center into
|
||||
// disk-local space here for a consistent intersection test.
|
||||
fn planet_hit(prev: vec3<f32>, cur: vec3<f32>, dir: vec3<f32>) -> vec4<f32> {
|
||||
var nearest_t = 1e9;
|
||||
var nearest_col = vec3<f32>(0.0);
|
||||
var found = false;
|
||||
for (var i: u32 = 0u; i < uniforms.planet_count; i = i + 1u) {
|
||||
let s = planets[i];
|
||||
let center = s.center.xyz;
|
||||
// Planet centers are stored in world space; rotate into disk-local space
|
||||
// to match the ray's coordinate system.
|
||||
let center = rot_x(s.center.xyz, -uniforms.disk_tilt);
|
||||
let radius = s.center.w;
|
||||
// Ray-sphere intersection for the segment.
|
||||
let seg = cur - prev;
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::storage::ShaderBuffer;
|
||||
use bevy::sprite_render::Material2dPlugin;
|
||||
|
||||
use super::material::BlackHoleMaterial;
|
||||
|
||||
/// Marks the full-screen quad so the resize system can find and rescale it
|
||||
/// when the window's aspect ratio changes (the mesh is built once at startup).
|
||||
/// when the window is resized (the mesh is built once at startup).
|
||||
#[derive(Component)]
|
||||
struct FullscreenQuad;
|
||||
|
||||
@ -21,7 +22,12 @@ impl Plugin for BlackHolePlugin {
|
||||
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
|
||||
.add_systems(
|
||||
Update,
|
||||
(crate::camera::orbit_controller, mirror_params, fit_quad_to_window),
|
||||
(
|
||||
crate::camera::orbit_controller,
|
||||
mirror_params,
|
||||
fit_quad_to_window,
|
||||
nudge_camera,
|
||||
),
|
||||
)
|
||||
.add_systems(Update, crate::scene::planets::upload_planets)
|
||||
// bevy_egui 0.41 requires UI systems to run inside the egui context
|
||||
@ -34,41 +40,70 @@ fn spawn_fullscreen_quad(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<BlackHoleMaterial>>,
|
||||
mut buffers: ResMut<Assets<ShaderBuffer>>,
|
||||
window: Query<&Window>,
|
||||
) {
|
||||
let win = match window.single() {
|
||||
Ok(w) => w,
|
||||
Err(_) => return,
|
||||
};
|
||||
let aspect = win.width() / win.height();
|
||||
// The mesh is a unit square (2x2 covers [-1,1]); we rescale via Transform
|
||||
// in fit_quad_to_window whenever the aspect changes.
|
||||
// Camera2d's default projection is ScalingMode::WindowSize (1 world unit =
|
||||
// 1 pixel, view centered at origin spanning [-w/2,w/2]×[-h/2,h/2]). A unit
|
||||
// quad (2×2) scaled by (w/2, h/2) fills the screen. fit_quad_to_window
|
||||
// updates this on resize.
|
||||
let half_w = win.width() / 2.0;
|
||||
let half_h = win.height() / 2.0;
|
||||
// CRITICAL: the planets storage binding (Handle<ShaderBuffer>) must point
|
||||
// at a REAL buffer asset, not Handle::default(). A default handle makes
|
||||
// AsBindGroup return RetryNextUpdate every frame, which silently skips the
|
||||
// quad's draw — the screen shows only the camera clear color. Pre-fill a
|
||||
// MAX_PLANETS-sized buffer of zeroed SphereData; upload_planets updates it.
|
||||
let planets_buffer = buffers.add(ShaderBuffer::from(vec![
|
||||
super::material::SphereData::default(
|
||||
);
|
||||
super::material::MAX_PLANETS
|
||||
]));
|
||||
let mut material = BlackHoleMaterial::default();
|
||||
material.planets = planets_buffer;
|
||||
commands.spawn((
|
||||
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||
MeshMaterial2d(materials.add(BlackHoleMaterial::default())),
|
||||
Transform::default().with_scale(Vec3::new(aspect, 1.0, 1.0)),
|
||||
MeshMaterial2d(materials.add(material)),
|
||||
Transform::default().with_scale(Vec3::new(half_w, half_h, 1.0)),
|
||||
FullscreenQuad,
|
||||
));
|
||||
commands.spawn(Camera2d);
|
||||
}
|
||||
|
||||
/// Rescale the full-screen quad so it always covers the camera's view, even
|
||||
/// after the window is resized. Camera2d's default projection spans y∈[-1,1]
|
||||
/// and x∈[-aspect, aspect]; scaling the unit quad by (aspect, 1, 1) fills it.
|
||||
fn fit_quad_to_window(window: Query<&Window>, mut quad: Query<&mut Transform, With<FullscreenQuad>>) {
|
||||
/// Rescale the full-screen quad to the live window size so it always fills the
|
||||
/// camera's view (Camera2d default projection: 1 world unit = 1 pixel).
|
||||
fn fit_quad_to_window(
|
||||
window: Query<&Window>,
|
||||
mut quad: Query<&mut Transform, With<FullscreenQuad>>,
|
||||
) {
|
||||
let win = match window.single() {
|
||||
Ok(w) => w,
|
||||
Err(_) => return,
|
||||
};
|
||||
let aspect = win.width() / win.height();
|
||||
let target = Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0);
|
||||
for mut transform in &mut quad {
|
||||
// Only mutate on change to avoid spamming change detection.
|
||||
if transform.scale.x != aspect {
|
||||
transform.scale.x = aspect;
|
||||
if transform.scale != target {
|
||||
transform.scale = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Workaround for Bevy 0.19 issue #24448: with a static camera the world stops
|
||||
/// rendering after the first frame. Oscillate the camera transform by a
|
||||
/// sub-pixel amount each frame so the view matrix changes and the render graph
|
||||
/// keeps producing frames. Amplitude is far below one pixel, so the image is
|
||||
/// visually stable. Remove when the upstream regression is fixed.
|
||||
fn nudge_camera(time: Res<Time>, mut camera: Query<&mut Transform, With<Camera2d>>) {
|
||||
let nudge = (time.elapsed_secs() * 5.0).sin() * 1e-3;
|
||||
for mut t in &mut camera {
|
||||
t.translation.x = nudge;
|
||||
}
|
||||
}
|
||||
|
||||
fn mirror_params(
|
||||
camera: Res<crate::camera::OrbitCamera>,
|
||||
params: Res<crate::params::BlackHoleParams>,
|
||||
|
||||
@ -44,7 +44,6 @@ pub fn upload_planets(
|
||||
// Build (or rebuild) the ShaderBuffer and share its handle across materials.
|
||||
let buffer = ShaderBuffer::from(data);
|
||||
for (_, mat) in materials.iter_mut() {
|
||||
// Replace the handle each frame (simple, correct; cheap for one material).
|
||||
mat.planets = buffers.add(buffer.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user