From 893190449508f0d2b75124c0fb7542959072f106 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 10 Jul 2026 17:06:19 +0800 Subject: [PATCH] feat: lensed planet ray-sphere intersection in geodesic tracer Add planets as ray-traced spheres inside the RK4 geodesic loop. A new planets.wgsl shader tests each integrator segment against a storage buffer of SphereData (center/radius/color/emissive) and composites hits front-to-back alongside the accretion disk. Lambert shading with a fixed light direction; emissive flag bypasses shading. - assets/shaders/planets.wgsl: segment-sphere intersection + shading - src/scene/: Planet component, upload_planets system, default planet - black_hole.wgsl: wire planet_hit into the compositing loop - geodesic_schwarzschild.wgsl: replace tuple return with Deriv struct --- assets/shaders/black_hole.wgsl | 30 +++++------ assets/shaders/geodesic_schwarzschild.wgsl | 59 ++++++++------------- assets/shaders/planets.wgsl | 46 +++++++++++++++++ src/main.rs | 1 + src/render/plugin.rs | 2 + src/scene/mod.rs | 1 + src/scene/planets.rs | 60 ++++++++++++++++++++++ 7 files changed, 147 insertions(+), 52 deletions(-) create mode 100644 assets/shaders/planets.wgsl create mode 100644 src/scene/mod.rs create mode 100644 src/scene/planets.rs diff --git a/assets/shaders/black_hole.wgsl b/assets/shaders/black_hole.wgsl index 16ab6ce..c9f7a71 100644 --- a/assets/shaders/black_hole.wgsl +++ b/assets/shaders/black_hole.wgsl @@ -3,6 +3,7 @@ #import "shaders/geodesic_schwarzschild.wgsl" #import "shaders/stars.wgsl" #import "shaders/disk.wgsl" +#import "shaders/planets.wgsl" struct BlackHoleUniforms { eye: vec4, @@ -39,15 +40,12 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { uv.x *= aspect; let dir = ray_direction(uv); - // Work in disk-local space: rotate eye + dir by -disk_tilt around X so the - // disk lies on y=0. (disk_hit/disk_color assume disk-local coords.) 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); let steps = uniforms.steps; - // Front-to-back compositing. var accum_color = vec3(0.0); var accum_alpha = 0.0; @@ -55,12 +53,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { for (var i: u32 = 0u; i < steps; i = i + 1u) { let r = length(pos); if (r < uniforms.rs) { - // Captured: whatever we've composited so far is the result. break; } if (r > 1000.0) { - // Escaped: add background stars along the (disk-local) final dir. - // Rotate back to world for the star sample. let world_dir = normalize(rot_x(d, uniforms.disk_tilt)); let star = star_color(world_dir, uniforms.star_intensity); accum_color += (1.0 - accum_alpha) * star; @@ -68,25 +63,30 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { break; } - // RK4 step (single step), then test disk crossing on the segment. - let (k1p, k1d) = deriv(pos, d); - let (k2p, k2d) = deriv(pos + k1p * dt * 0.5, normalize(d + k1d * dt * 0.5)); - let (k3p, k3d) = deriv(pos + k2p * dt * 0.5, normalize(d + k2d * dt * 0.5)); - let (k4p, k4d) = deriv(pos + k3p * dt, normalize(d + k3d * dt)); - let new_pos = pos + (k1p + 2.0*k2p + 2.0*k3p + k4p) * dt / 6.0; - let new_dir = normalize(d + (k1d + 2.0*k2d + 2.0*k3d + k4d) * dt / 6.0); + let k1 = deriv(pos, d); + let k2 = deriv(pos + k1.dpos * dt * 0.5, normalize(d + k1.ddir * dt * 0.5)); + let k3 = deriv(pos + k2.dpos * dt * 0.5, normalize(d + k2.ddir * dt * 0.5)); + let k4 = deriv(pos + k3.dpos * dt, normalize(d + k3.ddir * dt)); + let new_pos = pos + (k1.dpos + 2.0*k2.dpos + 2.0*k3.dpos + k4.dpos) * dt / 6.0; + let new_dir = normalize(d + (k1.ddir + 2.0*k2.ddir + 2.0*k3.ddir + k4.ddir) * dt / 6.0); if (disk_hit(prev, new_pos)) { - // Approximate the crossing point by interpolating to y=0. let ty = prev.y / (prev.y - new_pos.y); let hit = mix(prev, new_pos, vec3(ty)); let dc = disk_color(hit, new_dir); - let a = 0.85; // disk is nearly opaque + let a = 0.85; accum_color += (1.0 - accum_alpha) * dc * a; accum_alpha += (1.0 - accum_alpha) * a; if (accum_alpha > 0.99) { break; } } + let ph = planet_hit(prev, new_pos, new_dir); + if (ph.w > 0.0) { + accum_color += (1.0 - accum_alpha) * ph.xyz * ph.w; + accum_alpha += (1.0 - accum_alpha) * ph.w; + if (accum_alpha > 0.99) { break; } + } + prev = new_pos; pos = new_pos; d = new_dir; diff --git a/assets/shaders/geodesic_schwarzschild.wgsl b/assets/shaders/geodesic_schwarzschild.wgsl index a4d65ab..8553aa9 100644 --- a/assets/shaders/geodesic_schwarzschild.wgsl +++ b/assets/shaders/geodesic_schwarzschild.wgsl @@ -1,55 +1,40 @@ const R_ESCAPE: f32 = 1000.0; -// One RK4 sub-step derivative of (pos, dir) under the Schwarzschild -// bending acceleration. Rs is uniforms.rs. -fn deriv(pos: vec3, dir: vec3) -> (vec3, vec3) { - let r = length(pos); - let rs = uniforms.rs; - // Angular momentum squared: |cross(pos, dir)|^2 - let h = cross(pos, dir); - let h2 = dot(h, h); - // Avoid division by zero. - let r5 = max(r * r * r * r * r, 1e-6); - // d(pos)/dt = dir - let dpos = dir; - // d(dir)/dt = bending acceleration (re-normalized each step in integrate) - let accel = -1.5 * rs * h2 / r5 * pos; - return (dpos, accel); +struct Deriv { + dpos: vec3, + ddir: vec3, +} + +fn deriv(pos: vec3, dir: vec3) -> Deriv { + let r = length(pos); + let rs = uniforms.rs; + let h = cross(pos, dir); + let h2 = dot(h, h); + let r5 = max(r * r * r * r * r, 1e-6); + let dpos = dir; + let accel = -1.5 * rs * h2 / r5 * pos; + return Deriv(dpos, accel); } -// Integrate a ray from `pos` along `dir`. Returns: -// .status: 0 = escaped, 1 = captured (shadow) -// .final_pos, .final_dir: end state (used for sky sampling on escape) struct RayResult { status: u32, final_pos: vec3, final_dir: vec3, } -// Accumulator callback pattern: the caller passes a function-style body via -// a per-step check. Because WGSL has no first-class closures, we inline the -// per-step intersection tests in black_hole.wgsl's integrate_and_trace(). -// This function returns ONLY the escape/capture classification, used as a -// fallback when no scene object is hit. fn classify_ray(start_pos: vec3, start_dir: vec3, steps: u32, dt: f32) -> RayResult { var pos = start_pos; var dir = start_dir; for (var i: u32 = 0u; i < steps; i = i + 1u) { let r = length(pos); - if (r < uniforms.rs) { - return RayResult(1u, pos, dir); - } - if (r > R_ESCAPE) { - return RayResult(0u, pos, dir); - } - // RK4 - let (k1p, k1d) = deriv(pos, dir); - let (k2p, k2d) = deriv(pos + k1p * dt * 0.5, normalize(dir + k1d * dt * 0.5)); - let (k3p, k3d) = deriv(pos + k2p * dt * 0.5, normalize(dir + k2d * dt * 0.5)); - let (k4p, k4d) = deriv(pos + k3p * dt, normalize(dir + k3d * dt)); - pos = pos + (k1p + 2.0 * k2p + 2.0 * k3p + k4p) * dt / 6.0; - dir = normalize(dir + (k1d + 2.0 * k2d + 2.0 * k3d + k4d) * dt / 6.0); + if (r < uniforms.rs) { return RayResult(1u, pos, dir); } + if (r > R_ESCAPE) { return RayResult(0u, pos, dir); } + let k1 = deriv(pos, dir); + let k2 = deriv(pos + k1.dpos * dt * 0.5, normalize(dir + k1.ddir * dt * 0.5)); + let k3 = deriv(pos + k2.dpos * dt * 0.5, normalize(dir + k2.ddir * dt * 0.5)); + let k4 = deriv(pos + k3.dpos * dt, normalize(dir + k3.ddir * dt)); + pos = pos + (k1.dpos + 2.0 * k2.dpos + 2.0 * k3.dpos + k4.dpos) * dt / 6.0; + dir = normalize(dir + (k1.ddir + 2.0 * k2.ddir + 2.0 * k3.ddir + k4.ddir) * dt / 6.0); } - // Ran out of steps without a clear verdict: treat as escaped. return RayResult(0u, pos, dir); } diff --git a/assets/shaders/planets.wgsl b/assets/shaders/planets.wgsl new file mode 100644 index 0000000..7d05b52 --- /dev/null +++ b/assets/shaders/planets.wgsl @@ -0,0 +1,46 @@ +struct SphereData { + center: vec4, // xyz = center, w = radius + color: vec4, // xyz = color, w = emissive flag (u32 reinterpreted; we just check > 0.5) +}; + +@group(#{MATERIAL_BIND_GROUP}) @binding(3) var planets: array; + +// 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). +fn planet_hit(prev: vec3, cur: vec3, dir: vec3) -> vec4 { + var nearest_t = 1e9; + var nearest_col = vec3(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; + let radius = s.center.w; + // Ray-sphere intersection for the segment. + let seg = cur - prev; + let oc = prev - center; + let a = dot(seg, seg); + let b = 2.0 * dot(oc, seg); + let c = dot(oc, oc) - radius * radius; + let disc = b * b - 4.0 * a * c; + if (disc < 0.0) { continue; } + let sq = sqrt(disc); + var t = (-b - sq) / (2.0 * a); + if (t < 0.0) { t = (-b + sq) / (2.0 * a); } + if (t >= 0.0 && t <= 1.0 && t < nearest_t) { + nearest_t = t; + let hit_pos = prev + seg * t; + let n = normalize(hit_pos - center); + // Lambert shading from a fixed light direction. + let light_dir = normalize(vec3(0.5, 0.8, 0.3)); + let ndl = max(dot(n, light_dir), 0.0); + var col = s.color.xyz * (0.2 + 0.8 * ndl); + if (s.color.w > 0.5) { col = s.color.xyz; } // emissive + nearest_col = col; + found = true; + } + } + if (found) { + return vec4(nearest_col, 0.95); + } + return vec4(0.0, 0.0, 0.0, 0.0); +} diff --git a/src/main.rs b/src/main.rs index 9de0821..bc3d9c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod render; mod camera; mod params; mod ui; +mod scene; fn main() { // On web, abort startup if WebGPU isn't available and show a message. diff --git a/src/render/plugin.rs b/src/render/plugin.rs index fd3016d..f8ce3a9 100644 --- a/src/render/plugin.rs +++ b/src/render/plugin.rs @@ -12,10 +12,12 @@ impl Plugin for BlackHolePlugin { .add_plugins(Material2dPlugin::::default()) .add_plugins(bevy_egui::EguiPlugin::default()) .add_systems(Startup, spawn_fullscreen_quad) + .add_systems(Startup, crate::scene::planets::spawn_default_planet) .add_systems( Update, (crate::camera::orbit_controller, mirror_params), ) + .add_systems(Update, crate::scene::planets::upload_planets) // bevy_egui 0.41 requires UI systems to run inside the egui context // pass (fonts/ctx are initialized there); placing them in Update panics. .add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system); diff --git a/src/scene/mod.rs b/src/scene/mod.rs new file mode 100644 index 0000000..591f76e --- /dev/null +++ b/src/scene/mod.rs @@ -0,0 +1 @@ +pub mod planets; diff --git a/src/scene/planets.rs b/src/scene/planets.rs new file mode 100644 index 0000000..05d73e8 --- /dev/null +++ b/src/scene/planets.rs @@ -0,0 +1,60 @@ +use bevy::prelude::*; +use bevy::render::storage::ShaderBuffer; + +use crate::render::material::{SphereData, MAX_PLANETS}; + +/// A planet rendered as a lensed sphere inside the geodesic shader. +#[derive(Component, Clone, Copy)] +pub struct Planet { + pub center: Vec3, + pub radius: f32, + pub color: Vec3, + pub emissive: bool, +} + +/// Collects all Planet components, builds a fixed-size Vec (padded +/// to MAX_PLANETS), wraps it in a ShaderBuffer, and ensures every BlackHoleMaterial +/// points its `planets` handle at that buffer. Also updates planet_count in params. +/// +/// NOTE: the material field is `Handle` (Bevy 0.19 AsBindGroup +/// requirement). We create one ShaderBuffer asset and have all materials share it. +pub fn upload_planets( + planets: Query<&Planet>, + mut params: ResMut, + mut materials: ResMut>, + mut buffers: ResMut>, +) { + let mut data: Vec = planets + .iter() + .take(MAX_PLANETS) + .map(|p| SphereData { + center: p.center, + radius: p.radius, + color: p.color, + emissive: p.emissive as u32, + _pad0: 0.0, + _pad1: 0.0, + _pad2: 0.0, + }) + .collect(); + // Pad to MAX_PLANETS so the buffer size is constant (avoids reallocation churn). + data.resize(MAX_PLANETS, SphereData::default()); + params.planet_count = planets.iter().count().min(MAX_PLANETS) as u32; + + // 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()); + } +} + +/// Spawns a default test planet behind/above the hole so lensing is visible. +pub fn spawn_default_planet(mut commands: Commands) { + commands.spawn(Planet { + center: Vec3::new(0.0, 2.0, -25.0), + radius: 2.0, + color: Vec3::new(0.3, 0.5, 1.0), + emissive: false, + }); +}