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
This commit is contained in:
xfy 2026-07-10 17:06:19 +08:00
parent 983413e9af
commit 8931904495
7 changed files with 147 additions and 52 deletions

View File

@ -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<f32>,
@ -39,15 +40,12 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32>(0.0);
var accum_alpha = 0.0;
@ -55,12 +53,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
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<f32> {
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<f32>(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;

View File

@ -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<f32>, dir: vec3<f32>) -> (vec3<f32>, vec3<f32>) {
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<f32>,
ddir: vec3<f32>,
}
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> 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<f32>,
final_dir: vec3<f32>,
}
// 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<f32>, start_dir: vec3<f32>, 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);
}

View File

@ -0,0 +1,46 @@
struct SphereData {
center: vec4<f32>, // xyz = center, w = radius
color: vec4<f32>, // xyz = color, w = emissive flag (u32 reinterpreted; we just check > 0.5)
};
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var<storage, read> planets: array<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).
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;
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<f32>(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<f32>(nearest_col, 0.95);
}
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}

View File

@ -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.

View File

@ -12,10 +12,12 @@ impl Plugin for BlackHolePlugin {
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::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);

1
src/scene/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod planets;

60
src/scene/planets.rs Normal file
View File

@ -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<SphereData> (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<ShaderBuffer>` (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<crate::params::BlackHoleParams>,
mut materials: ResMut<Assets<crate::render::material::BlackHoleMaterial>>,
mut buffers: ResMut<Assets<ShaderBuffer>>,
) {
let mut data: Vec<SphereData> = 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,
});
}