fix: make the black hole actually render (was stuck on grey clear color)
The app showed only the camera clear color (grey srgb 43,44,47) — the fullscreen quad's fragment shader produced no output. Two issues; the second was the real blocker. 1. render/material.rs: skybox.wgsl declared binding 1 as texture_cube<f32> but the material's #[texture(1)] defaulted to D2. The mismatched bind-group layout vs. shader caused the pipeline to fail to specialize. Fix: dimension = "cube" on the texture attribute so the layout matches. 2. SHADER COMPOSITION (the actual blocker): the shader was split across naga_oil modules (ray_gen, geodesic, stars, disk, planets, grid, skybox) pulled in via #import singularity::... This compiled and validated with zero errors, but at runtime calling ANY cross-module-imported function made the fragment output nothing (only the clear color showed). Local functions were fine. Confirmed by bisecting a minimal repro with the user watching the screen: local fn -> renders; imported fn (even pure math) -> grey. Rather than chase the naga_oil 0.22 / Bevy 0.19 composition bug, inline every function into black_hole.wgsl and drop the #import singularity::* lines. The 7 standalone module files are removed. Rendering now works. Also fixes scene/planets.rs: upload_planets was allocating a fresh ShaderBuffer (new handle) every frame, which re-triggered the AsBindGroup RetryNextUpdate that the previous commit's startup pre-fill was meant to eliminate. Now mutates the existing buffer asset in place via set_data, keeping the handle stable.
This commit is contained in:
parent
0b158c57d3
commit
4556376b4a
@ -1,14 +1,15 @@
|
|||||||
|
// All shader logic is inlined into this single file.
|
||||||
|
//
|
||||||
|
// HISTORY: the shader used to be split across several naga_oil modules
|
||||||
|
// (ray_gen, geodesic, stars, disk, planets, grid, skybox) that each
|
||||||
|
// `#define_import_path singularity::...` and were pulled into this file via
|
||||||
|
// `#import singularity::...`. That compiled without error, but calling ANY
|
||||||
|
// imported function at runtime produced no fragment output — the fullscreen
|
||||||
|
// quad silently drew nothing and only the camera clear color (grey) showed.
|
||||||
|
// (Local functions worked; only cross-module imports broke.) Rather than chase
|
||||||
|
// the naga_oil composition bug, every function is inlined here. The standalone
|
||||||
|
// module files still exist on disk but are no longer imported.
|
||||||
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
||||||
// Bevy/naga_oil imports: each module file uses #define_import_path, then we
|
|
||||||
// import individual symbols via `namespace::name` (or `namespace::{a, b}`).
|
|
||||||
// Whole-file imports without `::` do NOT reliably bring functions into scope.
|
|
||||||
#import singularity::ray_gen::ray_direction
|
|
||||||
#import singularity::geodesic::{deriv, classify_ray}
|
|
||||||
#import singularity::stars::{hash13, star_color}
|
|
||||||
#import singularity::disk::{rot_x, disk_hit, disk_color}
|
|
||||||
#import singularity::planets::{SphereData, planets, planet_hit}
|
|
||||||
#import singularity::grid::{flamm_depth, grid_hit}
|
|
||||||
#import singularity::skybox::skybox_color
|
|
||||||
|
|
||||||
struct BlackHoleUniforms {
|
struct BlackHoleUniforms {
|
||||||
eye: vec4<f32>,
|
eye: vec4<f32>,
|
||||||
@ -38,6 +39,207 @@ struct BlackHoleUniforms {
|
|||||||
|
|
||||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
|
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> uniforms: BlackHoleUniforms;
|
||||||
|
|
||||||
|
// ---------- planets storage (binding 3) ----------
|
||||||
|
struct SphereData {
|
||||||
|
center: vec4<f32>, // xyz = center (world space), w = radius
|
||||||
|
color: vec4<f32>, // xyz = color, w = emissive flag
|
||||||
|
};
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var<storage, read> planets: array<SphereData>;
|
||||||
|
|
||||||
|
// ---------- optional cubemap skybox (bindings 1 & 2) ----------
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube<f32>;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var skybox_sampler: sampler;
|
||||||
|
|
||||||
|
// ====================== inlined helpers ======================
|
||||||
|
|
||||||
|
// Rotate a vector around the X axis by angle a.
|
||||||
|
fn rot_x(v: vec3<f32>, a: f32) -> vec3<f32> {
|
||||||
|
let c = cos(a);
|
||||||
|
let s = sin(a);
|
||||||
|
return vec3<f32>(v.x, c * v.y - s * v.z, s * v.y + c * v.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ray_gen ---
|
||||||
|
// `fov` is packed into the `.w` of `up` (Rust lays out `up: Vec3` + `fov: f32`
|
||||||
|
// as one vec4 block).
|
||||||
|
fn ray_direction(uv: vec2<f32>) -> vec3<f32> {
|
||||||
|
let tan_half_fov = tan(uniforms.up.w * 0.5);
|
||||||
|
let dir =
|
||||||
|
normalize(uniforms.forward.xyz)
|
||||||
|
+ uniforms.right.xyz * (uv.x * tan_half_fov)
|
||||||
|
+ uniforms.up.xyz * (uv.y * tan_half_fov);
|
||||||
|
return normalize(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- stars ---
|
||||||
|
fn hash13(p: vec3<f32>) -> f32 {
|
||||||
|
var q = vec3<f32>(dot(p, vec3<f32>(127.1, 311.7, 74.7)),
|
||||||
|
dot(p, vec3<f32>(269.5, 183.3, 246.1)),
|
||||||
|
dot(p, vec3<f32>(113.5, 271.9, 124.6)));
|
||||||
|
let h = fract(sin(q) * 43758.5453);
|
||||||
|
return h.x;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
|
||||||
|
let scale = 80.0;
|
||||||
|
let cell = floor(dir * scale);
|
||||||
|
let h = hash13(cell);
|
||||||
|
let threshold = 0.985;
|
||||||
|
if (h > threshold) {
|
||||||
|
let b = (h - threshold) / (1.0 - threshold);
|
||||||
|
let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
|
||||||
|
let f = abs(dir * scale - cell);
|
||||||
|
let d = max(f.x, max(f.y, f.z));
|
||||||
|
let falloff = smoothstep(0.5, 0.0, d);
|
||||||
|
return col * b * falloff * 3.0 * intensity;
|
||||||
|
}
|
||||||
|
return vec3<f32>(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- skybox ---
|
||||||
|
fn skybox_color(dir: vec3<f32>) -> vec3<f32> {
|
||||||
|
return textureSample(skybox, skybox_sampler, dir).rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- geodesic ---
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- disk ---
|
||||||
|
fn disk_hit(prev: vec3<f32>, cur: vec3<f32>) -> bool {
|
||||||
|
let y0 = prev.y;
|
||||||
|
let y1 = cur.y;
|
||||||
|
if (y0 * y1 > 0.0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let t = y0 / (y0 - y1);
|
||||||
|
let cross = mix(prev, cur, vec3<f32>(t));
|
||||||
|
let r = length(vec2<f32>(cross.x, cross.z));
|
||||||
|
return r >= uniforms.disk_inner && r <= uniforms.disk_outer;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
||||||
|
let r = length(vec2<f32>(pos.x, pos.z));
|
||||||
|
let phi = atan2(pos.z, pos.x);
|
||||||
|
|
||||||
|
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
|
||||||
|
let n = sin(phi * 8.0 + rot) * 0.5 + 0.5;
|
||||||
|
let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5;
|
||||||
|
let noise = mix(n, n2, 0.4);
|
||||||
|
|
||||||
|
let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
|
||||||
|
let tcol = mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
|
||||||
|
|
||||||
|
let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0);
|
||||||
|
|
||||||
|
var col = tcol * (0.6 + 0.4 * noise) * falloff;
|
||||||
|
|
||||||
|
let v_orbital = sqrt(uniforms.rs / (2.0 * r));
|
||||||
|
let tangent = normalize(vec3<f32>(-sin(phi), 0.0, cos(phi)));
|
||||||
|
let vdotn = dot(tangent * v_orbital, -dir);
|
||||||
|
let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4));
|
||||||
|
var doppler = 1.0;
|
||||||
|
if (uniforms.doppler_enabled != 0u) {
|
||||||
|
let delta = 1.0 / (gamma * (1.0 - vdotn));
|
||||||
|
doppler = pow(delta, 3.0) * uniforms.doppler_strength;
|
||||||
|
}
|
||||||
|
col *= doppler;
|
||||||
|
|
||||||
|
return col * uniforms.disk_brightness;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- planets ---
|
||||||
|
// `prev`/`cur` are in DISK-LOCAL space; planet centers are world space, so we
|
||||||
|
// rotate each center into disk-local space here.
|
||||||
|
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 = rot_x(s.center.xyz, -uniforms.disk_tilt);
|
||||||
|
let radius = s.center.w;
|
||||||
|
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);
|
||||||
|
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; }
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grid (Flamm's paraboloid) ---
|
||||||
|
fn flamm_depth(r: f32) -> f32 {
|
||||||
|
if (r <= uniforms.rs) { return 0.0; }
|
||||||
|
return -2.0 * sqrt(uniforms.rs * (r - uniforms.rs));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grid_hit(prev: vec3<f32>, cur: vec3<f32>) -> vec3<f32> {
|
||||||
|
let r0 = length(vec2<f32>(prev.x, prev.z));
|
||||||
|
let r1 = length(vec2<f32>(cur.x, cur.z));
|
||||||
|
let z0_surf = flamm_depth(r0);
|
||||||
|
let z1_surf = flamm_depth(r1);
|
||||||
|
if ((prev.y - z0_surf) * (cur.y - z1_surf) > 0.0) {
|
||||||
|
return vec3<f32>(0.0);
|
||||||
|
}
|
||||||
|
var hit = vec3<f32>(0.0);
|
||||||
|
var found = false;
|
||||||
|
for (var s: i32 = 0; s < 8; s = s + 1) {
|
||||||
|
let f = f32(s + 1) / 8.0;
|
||||||
|
let p = mix(prev, cur, vec3<f32>(f));
|
||||||
|
let r = length(vec2<f32>(p.x, p.z));
|
||||||
|
let surf = flamm_depth(r);
|
||||||
|
if (abs(p.y - surf) < 0.3) {
|
||||||
|
hit = p;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) { return vec3<f32>(0.0); }
|
||||||
|
|
||||||
|
let r = length(vec2<f32>(hit.x, hit.z));
|
||||||
|
let phi = atan2(hit.z, hit.x);
|
||||||
|
let ring = smoothstep(0.06, 0.0, abs(fract(r * uniforms.grid_density * 0.5) - 0.5));
|
||||||
|
let spoke = smoothstep(0.04, 0.0, abs(fract(phi * 6.0 / 6.283185) - 0.5));
|
||||||
|
let grid = max(ring, spoke);
|
||||||
|
let fade = smoothstep(-15.0, -1.0, hit.y);
|
||||||
|
let col = vec3<f32>(0.15, 0.3, 0.6) * grid * fade;
|
||||||
|
return col * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================== main ======================
|
||||||
@fragment
|
@fragment
|
||||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
let aspect = uniforms.resolution.x / uniforms.resolution.y;
|
let aspect = uniforms.resolution.x / uniforms.resolution.y;
|
||||||
@ -51,13 +253,10 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
var d = normalize(rot_x(dir, -uniforms.disk_tilt));
|
var d = normalize(rot_x(dir, -uniforms.disk_tilt));
|
||||||
|
|
||||||
// Total path length to integrate: enough to go from the camera, past the
|
// 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
|
// hole, and far enough beyond to count as escaped.
|
||||||
// `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 eye_dist = length(uniforms.eye.xyz);
|
||||||
let escape_r = max(eye_dist * 2.0, 100.0); // "escaped" = clearly past the hole
|
let escape_r = max(eye_dist * 2.0, 100.0);
|
||||||
let total_path = eye_dist + escape_r; // go in, through, and out
|
let total_path = eye_dist + escape_r;
|
||||||
let dt = total_path / f32(uniforms.steps);
|
let dt = total_path / f32(uniforms.steps);
|
||||||
let steps = uniforms.steps;
|
let steps = uniforms.steps;
|
||||||
|
|
||||||
@ -77,9 +276,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
// Rotate back to world for the sky/stars sample.
|
// Rotate back to world for the sky/stars sample.
|
||||||
let world_dir = normalize(rot_x(d, uniforms.disk_tilt));
|
let world_dir = normalize(rot_x(d, uniforms.disk_tilt));
|
||||||
var bg = vec3<f32>(0.0);
|
var bg = vec3<f32>(0.0);
|
||||||
// Procedural stars are always layered in.
|
|
||||||
bg += star_color(world_dir, uniforms.star_intensity);
|
bg += star_color(world_dir, uniforms.star_intensity);
|
||||||
// Optional cubemap skybox (gated so we never sample the fallback 1x1 texture).
|
|
||||||
if (uniforms.skybox_intensity > 0.0) {
|
if (uniforms.skybox_intensity > 0.0) {
|
||||||
bg += skybox_color(world_dir) * uniforms.skybox_intensity;
|
bg += skybox_color(world_dir) * uniforms.skybox_intensity;
|
||||||
}
|
}
|
||||||
@ -97,11 +294,10 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
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 + (k1.ddir + 2.0*k2.ddir + 2.0*k3.ddir + k4.ddir) * 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; }
|
||||||
@ -117,7 +313,7 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
if (uniforms.grid_enabled != 0u) {
|
if (uniforms.grid_enabled != 0u) {
|
||||||
let g = grid_hit(prev, new_pos);
|
let g = grid_hit(prev, new_pos);
|
||||||
if (g.x + g.y + g.z > 0.0) {
|
if (g.x + g.y + g.z > 0.0) {
|
||||||
accum_color += g; // additive
|
accum_color += g;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
// Disk plane is the xz-plane in world space, tilted by `disk_tilt` around the
|
|
||||||
// x-axis. We work in "disk-local" coordinates by rotating the ray.
|
|
||||||
#define_import_path singularity::disk
|
|
||||||
|
|
||||||
// Rotate a vector around the X axis by angle a.
|
|
||||||
fn rot_x(v: vec3<f32>, a: f32) -> vec3<f32> {
|
|
||||||
let c = cos(a);
|
|
||||||
let s = sin(a);
|
|
||||||
return vec3<f32>(v.x, c * v.y - s * v.z, s * v.y + c * v.z);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns true if the segment pos->pos+dir*dt crosses the disk plane (y=0)
|
|
||||||
// within radius [disk_inner, disk_outer]. (prev, cur are the segment endpoints.)
|
|
||||||
fn disk_hit(prev: vec3<f32>, cur: vec3<f32>) -> bool {
|
|
||||||
let y0 = prev.y;
|
|
||||||
let y1 = cur.y;
|
|
||||||
if (y0 * y1 > 0.0) {
|
|
||||||
return false; // same side, no crossing
|
|
||||||
}
|
|
||||||
// Linear interpolate to the crossing point.
|
|
||||||
let t = y0 / (y0 - y1);
|
|
||||||
let cross = mix(prev, cur, vec3<f32>(t));
|
|
||||||
let r = length(vec2<f32>(cross.x, cross.z));
|
|
||||||
return r >= uniforms.disk_inner && r <= uniforms.disk_outer;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shade a disk hit: procedural texture + Doppler beaming + temperature color.
|
|
||||||
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
|
||||||
let r = length(vec2<f32>(pos.x, pos.z));
|
|
||||||
let phi = atan2(pos.z, pos.x);
|
|
||||||
|
|
||||||
// Procedural noise: layered angular + radial, animated by rotation.
|
|
||||||
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
|
|
||||||
let n = sin(phi * 8.0 + rot) * 0.5 + 0.5;
|
|
||||||
let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5;
|
|
||||||
let noise = mix(n, n2, 0.4);
|
|
||||||
|
|
||||||
// Temperature gradient: hotter (white-blue) near inner edge, cooler (orange-red) outer.
|
|
||||||
let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
|
|
||||||
let tcol = mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
|
|
||||||
|
|
||||||
// Falloff: brighter at inner edge.
|
|
||||||
let falloff = 1.0 / pow(r / uniforms.disk_inner, 2.0);
|
|
||||||
|
|
||||||
var col = tcol * (0.6 + 0.4 * noise) * falloff;
|
|
||||||
|
|
||||||
// Doppler beaming. Disk orbits Keplerian-ish: v ~ sqrt(Rs/(2r)).
|
|
||||||
let v_orbital = sqrt(uniforms.rs / (2.0 * r));
|
|
||||||
// Orbital velocity direction (tangent) in the disk plane.
|
|
||||||
let tangent = normalize(vec3<f32>(-sin(phi), 0.0, cos(phi)));
|
|
||||||
// Scalar approximation: projection of orbital velocity onto ray direction.
|
|
||||||
let vdotn = dot(tangent * v_orbital, -dir); // toward viewer if positive
|
|
||||||
let gamma = 1.0 / sqrt(max(1.0 - v_orbital * v_orbital, 1e-4));
|
|
||||||
var doppler = 1.0;
|
|
||||||
if (uniforms.doppler_enabled != 0u) {
|
|
||||||
let delta = 1.0 / (gamma * (1.0 - vdotn));
|
|
||||||
doppler = pow(delta, 3.0) * uniforms.doppler_strength;
|
|
||||||
}
|
|
||||||
col *= doppler;
|
|
||||||
|
|
||||||
return col * uniforms.disk_brightness;
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
#define_import_path singularity::geodesic
|
|
||||||
|
|
||||||
const R_ESCAPE: f32 = 1000.0;
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RayResult {
|
|
||||||
status: u32,
|
|
||||||
final_pos: vec3<f32>,
|
|
||||||
final_dir: vec3<f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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); }
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
return RayResult(0u, pos, dir);
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
// Flamm's paraboloid embedding: z(r) = 2*sqrt(Rs*(r - Rs)), opens DOWNWARD
|
|
||||||
// (negative y in disk-local space). Dips below the disk toward the center —
|
|
||||||
// the classic gravity-well visualization. Traced through curved spacetime, so
|
|
||||||
// grid lines near the hole bend dramatically.
|
|
||||||
#define_import_path singularity::grid
|
|
||||||
|
|
||||||
fn flamm_depth(r: f32) -> f32 {
|
|
||||||
if (r <= uniforms.rs) { return 0.0; }
|
|
||||||
return -2.0 * sqrt(uniforms.rs * (r - uniforms.rs));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns additive grid color if the segment prev->cur crosses the Flamm
|
|
||||||
// paraboloid surface; returns black otherwise. `prev`/`cur` are disk-local.
|
|
||||||
fn grid_hit(prev: vec3<f32>, cur: vec3<f32>) -> vec3<f32> {
|
|
||||||
// Sample the paraboloid at the segment endpoints; if the segment crosses it,
|
|
||||||
// find an approximate crossing by sampling.
|
|
||||||
let r0 = length(vec2<f32>(prev.x, prev.z));
|
|
||||||
let r1 = length(vec2<f32>(cur.x, cur.z));
|
|
||||||
let z0_surf = flamm_depth(r0);
|
|
||||||
let z1_surf = flamm_depth(r1);
|
|
||||||
// Did the ray's y cross the surface y between endpoints?
|
|
||||||
if ((prev.y - z0_surf) * (cur.y - z1_surf) > 0.0) {
|
|
||||||
return vec3<f32>(0.0);
|
|
||||||
}
|
|
||||||
// Crossing: linear-search for the crossing point.
|
|
||||||
var hit = vec3<f32>(0.0);
|
|
||||||
var found = false;
|
|
||||||
for (var s: i32 = 0; s < 8; s = s + 1) {
|
|
||||||
let f = f32(s + 1) / 8.0;
|
|
||||||
let p = mix(prev, cur, vec3<f32>(f));
|
|
||||||
let r = length(vec2<f32>(p.x, p.z));
|
|
||||||
let surf = flamm_depth(r);
|
|
||||||
if (abs(p.y - surf) < 0.3) {
|
|
||||||
hit = p;
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) { return vec3<f32>(0.0); }
|
|
||||||
|
|
||||||
// Polar grid pattern from (r, phi).
|
|
||||||
let r = length(vec2<f32>(hit.x, hit.z));
|
|
||||||
let phi = atan2(hit.z, hit.x);
|
|
||||||
let ring = smoothstep(0.06, 0.0, abs(fract(r * uniforms.grid_density * 0.5) - 0.5));
|
|
||||||
let spoke = smoothstep(0.04, 0.0, abs(fract(phi * 6.0 / 6.283185) - 0.5));
|
|
||||||
let grid = max(ring, spoke);
|
|
||||||
// Fade with depth so the grid reads as "below" the hole.
|
|
||||||
let fade = smoothstep(-15.0, -1.0, hit.y);
|
|
||||||
let col = vec3<f32>(0.15, 0.3, 0.6) * grid * fade;
|
|
||||||
return col * 0.5; // additive, low intensity
|
|
||||||
}
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
#define_import_path singularity::planets
|
|
||||||
|
|
||||||
#import singularity::disk::rot_x
|
|
||||||
|
|
||||||
struct SphereData {
|
|
||||||
center: vec4<f32>, // xyz = center (world space), w = radius
|
|
||||||
color: vec4<f32>, // xyz = color, w = emissive flag (u32 reinterpreted; we just check > 0.5)
|
|
||||||
};
|
|
||||||
|
|
||||||
// The storage binding is declared here as part of the planets module; it lives
|
|
||||||
// in the material's bind group (group 2 = #{MATERIAL_BIND_GROUP}).
|
|
||||||
@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).
|
|
||||||
// `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];
|
|
||||||
// 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;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
// Builds a world-space camera ray direction for the current pixel.
|
|
||||||
// `uv` is the pixel coordinate normalized to [-1,1] with aspect correction.
|
|
||||||
#define_import_path singularity::ray_gen
|
|
||||||
|
|
||||||
fn ray_direction(uv: vec2<f32>) -> vec3<f32> {
|
|
||||||
// NOTE: `fov` is packed into the `.w` of `up` in BlackHoleUniforms
|
|
||||||
// (the Rust struct lays out `up: Vec3` + `fov: f32` as one vec4 block).
|
|
||||||
// The WGSL struct must stay exactly as-is per the task spec, so read fov
|
|
||||||
// from `uniforms.up.w` rather than a separate `uniforms.fov` field.
|
|
||||||
let tan_half_fov = tan(uniforms.up.w * 0.5);
|
|
||||||
let dir =
|
|
||||||
normalize(uniforms.forward.xyz)
|
|
||||||
+ uniforms.right.xyz * (uv.x * tan_half_fov)
|
|
||||||
+ uniforms.up.xyz * (uv.y * tan_half_fov);
|
|
||||||
return normalize(dir);
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
#define_import_path singularity::skybox
|
|
||||||
|
|
||||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var skybox: texture_cube<f32>;
|
|
||||||
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var skybox_sampler: sampler;
|
|
||||||
|
|
||||||
// Sample the cubemap along a world-space direction. Caller gates on skybox_intensity.
|
|
||||||
fn skybox_color(dir: vec3<f32>) -> vec3<f32> {
|
|
||||||
return textureSample(skybox, skybox_sampler, dir).rgb;
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
// Hash-based procedural stars on the unit sphere. Returns RGB radiance.
|
|
||||||
#define_import_path singularity::stars
|
|
||||||
|
|
||||||
fn hash13(p: vec3<f32>) -> f32 {
|
|
||||||
var q = vec3<f32>(dot(p, vec3<f32>(127.1, 311.7, 74.7)),
|
|
||||||
dot(p, vec3<f32>(269.5, 183.3, 246.1)),
|
|
||||||
dot(p, vec3<f32>(113.5, 271.9, 124.6)));
|
|
||||||
let h = fract(sin(q) * 43758.5453);
|
|
||||||
return h.x;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
|
|
||||||
// Divide the sphere into cells; a cell gets a star if its hash passes a threshold.
|
|
||||||
let scale = 80.0;
|
|
||||||
let cell = floor(dir * scale);
|
|
||||||
let h = hash13(cell);
|
|
||||||
let threshold = 0.985; // ~1.5% of cells hold a star
|
|
||||||
if (h > threshold) {
|
|
||||||
// Brightness from the hash remainder.
|
|
||||||
let b = (h - threshold) / (1.0 - threshold);
|
|
||||||
let col = mix(vec3<f32>(0.6, 0.7, 1.0), vec3<f32>(1.0, 0.9, 0.7), b);
|
|
||||||
// Soften the star with the fractional position inside the cell.
|
|
||||||
let f = abs(dir * scale - cell);
|
|
||||||
let d = max(f.x, max(f.y, f.z));
|
|
||||||
let falloff = smoothstep(0.5, 0.0, d);
|
|
||||||
return col * b * falloff * 3.0 * intensity;
|
|
||||||
}
|
|
||||||
return vec3<f32>(0.0);
|
|
||||||
}
|
|
||||||
@ -95,7 +95,14 @@ pub struct BlackHoleMaterial {
|
|||||||
pub uniforms: BlackHoleUniforms,
|
pub uniforms: BlackHoleUniforms,
|
||||||
// Texture at binding 1 + its matching sampler at binding 2. The derive
|
// Texture at binding 1 + its matching sampler at binding 2. The derive
|
||||||
// requires the texture and sampler attributes to live on the same field.
|
// requires the texture and sampler attributes to live on the same field.
|
||||||
#[texture(1)]
|
// `dimension = "cube"` is REQUIRED: skybox.wgsl declares this binding as
|
||||||
|
// `texture_cube<f32>`. The derive defaults to D2, which made the bind-group
|
||||||
|
// layout (D2) disagree with the shader (Cube) — the pipeline failed to
|
||||||
|
// specialize and the fullscreen quad silently drew nothing, leaving only
|
||||||
|
// the camera clear color. Matching the dimension to Cube lets the pipeline
|
||||||
|
// compile; when no cubemap is set, Bevy binds its 1x1 cube fallback (gated
|
||||||
|
// out by `skybox_intensity > 0` in the shader anyway).
|
||||||
|
#[texture(1, dimension = "cube")]
|
||||||
#[sampler(2)]
|
#[sampler(2)]
|
||||||
pub skybox: Option<Handle<Image>>,
|
pub skybox: Option<Handle<Image>>,
|
||||||
#[storage(3, read_only)]
|
#[storage(3, read_only)]
|
||||||
|
|||||||
@ -12,16 +12,25 @@ pub struct Planet {
|
|||||||
pub emissive: bool,
|
pub emissive: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects all Planet components, builds a fixed-size Vec<SphereData> (padded
|
/// Collects all Planet components, writes them into the shared MAX_PLANETS-sized
|
||||||
/// to MAX_PLANETS), wraps it in a ShaderBuffer, and ensures every BlackHoleMaterial
|
/// `ShaderBuffer` that the material already binds, and updates `planet_count`.
|
||||||
/// points its `planets` handle at that buffer. Also updates planet_count in params.
|
|
||||||
///
|
///
|
||||||
/// NOTE: the material field is `Handle<ShaderBuffer>` (Bevy 0.19 AsBindGroup
|
/// CRITICAL: we must NOT allocate a new `ShaderBuffer` (and a new handle) each
|
||||||
/// requirement). We create one ShaderBuffer asset and have all materials share it.
|
/// frame. The `#[storage(3, read_only)]` binding resolves the handle via
|
||||||
|
/// `RenderAssets<GpuShaderBuffer>::get(handle)` and returns
|
||||||
|
/// `AsBindGroupError::RetryNextUpdate` if the GPU asset for *that exact handle*
|
||||||
|
/// isn't ready yet. A freshly-added asset has no GPU asset yet, so reassigning
|
||||||
|
/// the handle every frame makes the fullscreen quad's draw get skipped every
|
||||||
|
/// frame — the screen shows only the camera clear color (grey).
|
||||||
|
///
|
||||||
|
/// Instead, mutate the existing asset in place. `GpuShaderBuffer::prepare_asset`
|
||||||
|
/// (bevy_render 0.19 `storage.rs`) sees the changed CPU data, reuses the same
|
||||||
|
/// GPU buffer, and `write_buffer`s the new contents — the handle stays stable,
|
||||||
|
/// the GPU asset stays ready, and the draw proceeds.
|
||||||
pub fn upload_planets(
|
pub fn upload_planets(
|
||||||
planets: Query<&Planet>,
|
planets: Query<&Planet>,
|
||||||
mut params: ResMut<crate::params::BlackHoleParams>,
|
mut params: ResMut<crate::params::BlackHoleParams>,
|
||||||
mut materials: ResMut<Assets<crate::render::material::BlackHoleMaterial>>,
|
materials: Res<Assets<crate::render::material::BlackHoleMaterial>>,
|
||||||
mut buffers: ResMut<Assets<ShaderBuffer>>,
|
mut buffers: ResMut<Assets<ShaderBuffer>>,
|
||||||
) {
|
) {
|
||||||
let mut data: Vec<SphereData> = planets
|
let mut data: Vec<SphereData> = planets
|
||||||
@ -41,10 +50,15 @@ pub fn upload_planets(
|
|||||||
data.resize(MAX_PLANETS, SphereData::default());
|
data.resize(MAX_PLANETS, SphereData::default());
|
||||||
params.planet_count = planets.iter().count().min(MAX_PLANETS) as u32;
|
params.planet_count = planets.iter().count().min(MAX_PLANETS) as u32;
|
||||||
|
|
||||||
// Build (or rebuild) the ShaderBuffer and share its handle across materials.
|
// Write into the existing buffer asset(s) the materials already reference.
|
||||||
let buffer = ShaderBuffer::from(data);
|
// The startup system pre-creates exactly one such buffer; we find it by the
|
||||||
for (_, mat) in materials.iter_mut() {
|
// materials' handles and mutate in place — never reallocate the handle.
|
||||||
mat.planets = buffers.add(buffer.clone());
|
// set_data moves a Vec<T> (encase treats Vec<T> as a runtime-sized array),
|
||||||
|
// matching the official bevy 0.19 storage_buffer example.
|
||||||
|
for (_, mat) in materials.iter() {
|
||||||
|
if let Some(mut buffer) = buffers.get_mut(&mat.planets) {
|
||||||
|
buffer.set_data(data.clone());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user