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:
parent
983413e9af
commit
8931904495
@ -3,6 +3,7 @@
|
|||||||
#import "shaders/geodesic_schwarzschild.wgsl"
|
#import "shaders/geodesic_schwarzschild.wgsl"
|
||||||
#import "shaders/stars.wgsl"
|
#import "shaders/stars.wgsl"
|
||||||
#import "shaders/disk.wgsl"
|
#import "shaders/disk.wgsl"
|
||||||
|
#import "shaders/planets.wgsl"
|
||||||
|
|
||||||
struct BlackHoleUniforms {
|
struct BlackHoleUniforms {
|
||||||
eye: vec4<f32>,
|
eye: vec4<f32>,
|
||||||
@ -39,15 +40,12 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
uv.x *= aspect;
|
uv.x *= aspect;
|
||||||
let dir = ray_direction(uv);
|
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 pos = rot_x(uniforms.eye.xyz, -uniforms.disk_tilt);
|
||||||
var d = normalize(rot_x(dir, -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 dt = max(length(uniforms.eye.xyz), 20.0) / f32(uniforms.steps);
|
||||||
let steps = uniforms.steps;
|
let steps = uniforms.steps;
|
||||||
|
|
||||||
// Front-to-back compositing.
|
|
||||||
var accum_color = vec3<f32>(0.0);
|
var accum_color = vec3<f32>(0.0);
|
||||||
var accum_alpha = 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) {
|
for (var i: u32 = 0u; i < steps; i = i + 1u) {
|
||||||
let r = length(pos);
|
let r = length(pos);
|
||||||
if (r < uniforms.rs) {
|
if (r < uniforms.rs) {
|
||||||
// Captured: whatever we've composited so far is the result.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (r > 1000.0) {
|
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 world_dir = normalize(rot_x(d, uniforms.disk_tilt));
|
||||||
let star = star_color(world_dir, uniforms.star_intensity);
|
let star = star_color(world_dir, uniforms.star_intensity);
|
||||||
accum_color += (1.0 - accum_alpha) * star;
|
accum_color += (1.0 - accum_alpha) * star;
|
||||||
@ -68,25 +63,30 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RK4 step (single step), then test disk crossing on the segment.
|
let k1 = deriv(pos, d);
|
||||||
let (k1p, k1d) = deriv(pos, d);
|
let k2 = deriv(pos + k1.dpos * dt * 0.5, normalize(d + k1.ddir * dt * 0.5));
|
||||||
let (k2p, k2d) = deriv(pos + k1p * dt * 0.5, normalize(d + k1d * dt * 0.5));
|
let k3 = deriv(pos + k2.dpos * dt * 0.5, normalize(d + k2.ddir * dt * 0.5));
|
||||||
let (k3p, k3d) = deriv(pos + k2p * dt * 0.5, normalize(d + k2d * dt * 0.5));
|
let k4 = deriv(pos + k3.dpos * dt, normalize(d + k3.ddir * dt));
|
||||||
let (k4p, k4d) = deriv(pos + k3p * dt, normalize(d + k3d * dt));
|
let new_pos = pos + (k1.dpos + 2.0*k2.dpos + 2.0*k3.dpos + k4.dpos) * dt / 6.0;
|
||||||
let new_pos = pos + (k1p + 2.0*k2p + 2.0*k3p + k4p) * dt / 6.0;
|
let new_dir = normalize(d + (k1.ddir + 2.0*k2.ddir + 2.0*k3.ddir + k4.ddir) * dt / 6.0);
|
||||||
let new_dir = normalize(d + (k1d + 2.0*k2d + 2.0*k3d + k4d) * dt / 6.0);
|
|
||||||
|
|
||||||
if (disk_hit(prev, new_pos)) {
|
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 ty = prev.y / (prev.y - new_pos.y);
|
||||||
let hit = mix(prev, new_pos, vec3<f32>(ty));
|
let hit = mix(prev, new_pos, vec3<f32>(ty));
|
||||||
let dc = disk_color(hit, new_dir);
|
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_color += (1.0 - accum_alpha) * dc * a;
|
||||||
accum_alpha += (1.0 - accum_alpha) * a;
|
accum_alpha += (1.0 - accum_alpha) * a;
|
||||||
if (accum_alpha > 0.99) { break; }
|
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;
|
prev = new_pos;
|
||||||
pos = new_pos;
|
pos = new_pos;
|
||||||
d = new_dir;
|
d = new_dir;
|
||||||
|
|||||||
@ -1,55 +1,40 @@
|
|||||||
const R_ESCAPE: f32 = 1000.0;
|
const R_ESCAPE: f32 = 1000.0;
|
||||||
|
|
||||||
// One RK4 sub-step derivative of (pos, dir) under the Schwarzschild
|
struct Deriv {
|
||||||
// bending acceleration. Rs is uniforms.rs.
|
dpos: vec3<f32>,
|
||||||
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> (vec3<f32>, vec3<f32>) {
|
ddir: vec3<f32>,
|
||||||
let r = length(pos);
|
}
|
||||||
let rs = uniforms.rs;
|
|
||||||
// Angular momentum squared: |cross(pos, dir)|^2
|
fn deriv(pos: vec3<f32>, dir: vec3<f32>) -> Deriv {
|
||||||
let h = cross(pos, dir);
|
let r = length(pos);
|
||||||
let h2 = dot(h, h);
|
let rs = uniforms.rs;
|
||||||
// Avoid division by zero.
|
let h = cross(pos, dir);
|
||||||
let r5 = max(r * r * r * r * r, 1e-6);
|
let h2 = dot(h, h);
|
||||||
// d(pos)/dt = dir
|
let r5 = max(r * r * r * r * r, 1e-6);
|
||||||
let dpos = dir;
|
let dpos = dir;
|
||||||
// d(dir)/dt = bending acceleration (re-normalized each step in integrate)
|
let accel = -1.5 * rs * h2 / r5 * pos;
|
||||||
let accel = -1.5 * rs * h2 / r5 * pos;
|
return Deriv(dpos, accel);
|
||||||
return (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 {
|
struct RayResult {
|
||||||
status: u32,
|
status: u32,
|
||||||
final_pos: vec3<f32>,
|
final_pos: vec3<f32>,
|
||||||
final_dir: 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 {
|
fn classify_ray(start_pos: vec3<f32>, start_dir: vec3<f32>, steps: u32, dt: f32) -> RayResult {
|
||||||
var pos = start_pos;
|
var pos = start_pos;
|
||||||
var dir = start_dir;
|
var dir = start_dir;
|
||||||
for (var i: u32 = 0u; i < steps; i = i + 1u) {
|
for (var i: u32 = 0u; i < steps; i = i + 1u) {
|
||||||
let r = length(pos);
|
let r = length(pos);
|
||||||
if (r < uniforms.rs) {
|
if (r < uniforms.rs) { return RayResult(1u, pos, dir); }
|
||||||
return RayResult(1u, pos, dir);
|
if (r > R_ESCAPE) { return RayResult(0u, pos, dir); }
|
||||||
}
|
let k1 = deriv(pos, dir);
|
||||||
if (r > R_ESCAPE) {
|
let k2 = deriv(pos + k1.dpos * dt * 0.5, normalize(dir + k1.ddir * dt * 0.5));
|
||||||
return RayResult(0u, pos, dir);
|
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));
|
||||||
// RK4
|
pos = pos + (k1.dpos + 2.0 * k2.dpos + 2.0 * k3.dpos + k4.dpos) * dt / 6.0;
|
||||||
let (k1p, k1d) = deriv(pos, dir);
|
dir = normalize(dir + (k1.ddir + 2.0 * k2.ddir + 2.0 * k3.ddir + k4.ddir) * dt / 6.0);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
// Ran out of steps without a clear verdict: treat as escaped.
|
|
||||||
return RayResult(0u, pos, dir);
|
return RayResult(0u, pos, dir);
|
||||||
}
|
}
|
||||||
|
|||||||
46
assets/shaders/planets.wgsl
Normal file
46
assets/shaders/planets.wgsl
Normal 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);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ mod render;
|
|||||||
mod camera;
|
mod camera;
|
||||||
mod params;
|
mod params;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod scene;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// On web, abort startup if WebGPU isn't available and show a message.
|
// On web, abort startup if WebGPU isn't available and show a message.
|
||||||
|
|||||||
@ -12,10 +12,12 @@ impl Plugin for BlackHolePlugin {
|
|||||||
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
|
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
|
||||||
.add_plugins(bevy_egui::EguiPlugin::default())
|
.add_plugins(bevy_egui::EguiPlugin::default())
|
||||||
.add_systems(Startup, spawn_fullscreen_quad)
|
.add_systems(Startup, spawn_fullscreen_quad)
|
||||||
|
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(crate::camera::orbit_controller, mirror_params),
|
(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
|
// bevy_egui 0.41 requires UI systems to run inside the egui context
|
||||||
// pass (fonts/ctx are initialized there); placing them in Update panics.
|
// pass (fonts/ctx are initialized there); placing them in Update panics.
|
||||||
.add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system);
|
.add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system);
|
||||||
|
|||||||
1
src/scene/mod.rs
Normal file
1
src/scene/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod planets;
|
||||||
60
src/scene/planets.rs
Normal file
60
src/scene/planets.rs
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user