Compare commits
10 Commits
d7fdef541e
...
dcc79b8040
| Author | SHA1 | Date | |
|---|---|---|---|
| dcc79b8040 | |||
| 33ce1061ba | |||
| 7591b790b2 | |||
| c06f1543db | |||
| a7490c0c2f | |||
| ed6639738f | |||
| 6ccd0a173b | |||
| 0f33d80fd3 | |||
| 7d3a8a81ba | |||
| f31ef900d8 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
/dist
|
/dist
|
||||||
|
.zcode/
|
||||||
|
|||||||
@ -34,6 +34,10 @@ struct BlackHoleUniforms {
|
|||||||
planet_count: u32,
|
planet_count: u32,
|
||||||
steps: u32,
|
steps: u32,
|
||||||
spin: f32,
|
spin: f32,
|
||||||
|
star_aa: u32,
|
||||||
|
bloom_threshold: f32,
|
||||||
|
bloom_strength: f32,
|
||||||
|
exposure: f32,
|
||||||
_pad5: f32,
|
_pad5: f32,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,17 +86,29 @@ fn hash13(p: vec3<f32>) -> f32 {
|
|||||||
|
|
||||||
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
|
fn star_color(dir: vec3<f32>, intensity: f32) -> vec3<f32> {
|
||||||
let scale = 80.0;
|
let scale = 80.0;
|
||||||
let cell = floor(dir * scale);
|
let p = dir * scale;
|
||||||
|
let cell = floor(p);
|
||||||
let h = hash13(cell);
|
let h = hash13(cell);
|
||||||
let threshold = 0.985;
|
let threshold = 0.985;
|
||||||
if (h > threshold) {
|
if (h > threshold) {
|
||||||
let b = (h - threshold) / (1.0 - 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 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);
|
if (uniforms.star_aa != 0u) {
|
||||||
|
// Gaussian speck: distance to cell center, soft radial falloff.
|
||||||
|
// Produces a round 2-3 pixel anti-aliased disk instead of a square.
|
||||||
|
let center = cell + vec3<f32>(0.5);
|
||||||
|
let dist = length(p - center);
|
||||||
|
let radius = 0.25 + b * 0.4;
|
||||||
|
let falloff = exp(-dist * dist / (radius * radius));
|
||||||
|
return col * b * falloff * 4.0 * intensity;
|
||||||
|
} else {
|
||||||
|
// Original fast path: square-cell smoothstep (blocky but cheap).
|
||||||
|
let f = abs(p - cell);
|
||||||
let d = max(f.x, max(f.y, f.z));
|
let d = max(f.x, max(f.y, f.z));
|
||||||
let falloff = smoothstep(0.5, 0.0, d);
|
let falloff = smoothstep(0.5, 0.0, d);
|
||||||
return col * b * falloff * 3.0 * intensity;
|
return col * b * falloff * 3.0 * intensity;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return vec3<f32>(0.0);
|
return vec3<f32>(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,14 +156,64 @@ fn disk_hit(prev: vec3<f32>, cur: vec3<f32>) -> bool {
|
|||||||
return r >= uniforms.disk_inner && r <= uniforms.disk_outer;
|
return r >= uniforms.disk_inner && r <= uniforms.disk_outer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- disk noise (domain-warped FBM) ---
|
||||||
|
fn hash33(p: vec3<f32>) -> vec3<f32> {
|
||||||
|
let 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)),
|
||||||
|
);
|
||||||
|
return fract(sin(q) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_noise3(p: vec3<f32>) -> f32 {
|
||||||
|
let i = floor(p);
|
||||||
|
let f = fract(p);
|
||||||
|
let u = f * f * (3.0 - 2.0 * f);
|
||||||
|
let n000 = hash33(i + vec3<f32>(0.0, 0.0, 0.0)).x;
|
||||||
|
let n100 = hash33(i + vec3<f32>(1.0, 0.0, 0.0)).x;
|
||||||
|
let n010 = hash33(i + vec3<f32>(0.0, 1.0, 0.0)).x;
|
||||||
|
let n110 = hash33(i + vec3<f32>(1.0, 1.0, 0.0)).x;
|
||||||
|
let n001 = hash33(i + vec3<f32>(0.0, 0.0, 1.0)).x;
|
||||||
|
let n101 = hash33(i + vec3<f32>(1.0, 0.0, 1.0)).x;
|
||||||
|
let n011 = hash33(i + vec3<f32>(0.0, 1.0, 1.0)).x;
|
||||||
|
let n111 = hash33(i + vec3<f32>(1.0, 1.0, 1.0)).x;
|
||||||
|
let nx00 = mix(n000, n100, u.x);
|
||||||
|
let nx10 = mix(n010, n110, u.x);
|
||||||
|
let nx01 = mix(n001, n101, u.x);
|
||||||
|
let nx11 = mix(n011, n111, u.x);
|
||||||
|
let nxy0 = mix(nx00, nx10, u.y);
|
||||||
|
let nxy1 = mix(nx01, nx11, u.y);
|
||||||
|
return mix(nxy0, nxy1, u.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fbm3(p: vec3<f32>, octaves: u32) -> f32 {
|
||||||
|
var sum = 0.0;
|
||||||
|
var amp = 0.5;
|
||||||
|
var freq = 1.0;
|
||||||
|
for (var i: u32 = 0u; i < octaves; i = i + 1u) {
|
||||||
|
sum = sum + amp * value_noise3(p * freq);
|
||||||
|
freq = freq * 2.0;
|
||||||
|
amp = amp * 0.5;
|
||||||
|
}
|
||||||
|
return sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disk_noise(pos: vec3<f32>, t: f32) -> f32 {
|
||||||
|
let warp = fbm3(pos * 0.8 + vec3<f32>(0.0, 0.0, t * 0.1), 3u);
|
||||||
|
let n = fbm3(pos * 2.0 + warp * 1.5 + vec3<f32>(0.0, 0.0, t * 0.3), 4u);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
fn disk_color(pos: vec3<f32>, dir: vec3<f32>) -> vec3<f32> {
|
||||||
let r = length(vec2<f32>(pos.x, pos.z));
|
let r = length(vec2<f32>(pos.x, pos.z));
|
||||||
let phi = atan2(pos.z, pos.x);
|
let phi = atan2(pos.z, pos.x);
|
||||||
|
|
||||||
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
|
let rot = uniforms.time * uniforms.disk_rotation_speed / pow(r, 1.5);
|
||||||
let n = sin(phi * 8.0 + rot) * 0.5 + 0.5;
|
// Domain-warped FBM for feathered/smoky gas texture. The Keplerian shear
|
||||||
let n2 = sin(phi * 23.0 - rot * 1.7 + r * 2.0) * 0.5 + 0.5;
|
// (rot ∝ 1/r^1.5) is folded into the noise flow term so inner radii flow
|
||||||
let noise = mix(n, n2, 0.4);
|
// faster than outer — correct differential rotation.
|
||||||
|
let noise = disk_noise(vec3<f32>(pos.x * 0.3, pos.z * 0.3, rot), uniforms.time);
|
||||||
|
|
||||||
let t = (r - uniforms.disk_inner) / (uniforms.disk_outer - uniforms.disk_inner);
|
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 tcol = mix(vec3<f32>(1.0, 0.95, 0.85), vec3<f32>(1.0, 0.45, 0.12), clamp(t, 0.0, 1.0));
|
||||||
|
|||||||
52
assets/shaders/blur.wgsl
Normal file
52
assets/shaders/blur.wgsl
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
||||||
|
|
||||||
|
struct BlurUniform {
|
||||||
|
mode: u32, // 0 = downsample, 1 = upsample
|
||||||
|
texel_size: vec2<f32>,
|
||||||
|
blend: f32, // upsample blend factor (ignored for down)
|
||||||
|
_pad0: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> u: BlurUniform;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var tex: texture_2d<f32>;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var samp: sampler;
|
||||||
|
|
||||||
|
// 13-tap weighted kernel (Gaussian approximation for HDR bloom).
|
||||||
|
// NOTE: naga rejects dynamic indexing of const arrays ("may only be indexed
|
||||||
|
// by a constant"), so we use var<private> here — it allows runtime indexing.
|
||||||
|
var<private> KERNEL_OFFSETS: array<vec2<f32>, 13> = array<vec2<f32>, 13>(
|
||||||
|
vec2<f32>( 0.0, 0.0),
|
||||||
|
vec2<f32>( 1.0, 0.0), vec2<f32>(-1.0, 0.0),
|
||||||
|
vec2<f32>( 0.0, 1.0), vec2<f32>( 0.0, -1.0),
|
||||||
|
vec2<f32>( 1.0, 1.0), vec2<f32>(-1.0, 1.0),
|
||||||
|
vec2<f32>( 1.0, -1.0), vec2<f32>(-1.0, -1.0),
|
||||||
|
vec2<f32>( 2.0, 0.0), vec2<f32>(-2.0, 0.0),
|
||||||
|
vec2<f32>( 0.0, 2.0), vec2<f32>( 0.0, -2.0),
|
||||||
|
);
|
||||||
|
var<private> KERNEL_WEIGHTS: array<f32, 13> = array<f32, 13>(
|
||||||
|
0.5,
|
||||||
|
0.25, 0.25, 0.25, 0.25,
|
||||||
|
0.125, 0.125, 0.125, 0.125,
|
||||||
|
0.0625, 0.0625, 0.0625, 0.0625,
|
||||||
|
);
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
let uv = in.uv;
|
||||||
|
// Downsample and upsample share the same 13-tap weighted kernel.
|
||||||
|
// mode 0 = down (plain weighted average), mode 1 = up (scaled by blend).
|
||||||
|
var sum = vec3<f32>(0.0);
|
||||||
|
var wsum = 0.0;
|
||||||
|
for (var i: i32 = 0; i < 13; i = i + 1) {
|
||||||
|
let off = KERNEL_OFFSETS[i] * u.texel_size;
|
||||||
|
let c = textureSample(tex, samp, uv + off).rgb;
|
||||||
|
sum = sum + c * KERNEL_WEIGHTS[i];
|
||||||
|
wsum = wsum + KERNEL_WEIGHTS[i];
|
||||||
|
}
|
||||||
|
let blurred = sum / wsum;
|
||||||
|
if (u.mode == 0u) {
|
||||||
|
return vec4<f32>(blurred, 1.0);
|
||||||
|
} else {
|
||||||
|
return vec4<f32>(blurred * u.blend, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
assets/shaders/brightpass.wgsl
Normal file
23
assets/shaders/brightpass.wgsl
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
||||||
|
|
||||||
|
struct BrightPassUniform {
|
||||||
|
threshold: f32,
|
||||||
|
_pad0: f32,
|
||||||
|
_pad1: f32,
|
||||||
|
_pad2: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> u: BrightPassUniform;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var tex: texture_2d<f32>;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var samp: sampler;
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
let hdr = textureSample(tex, samp, in.uv).rgb;
|
||||||
|
let lum = dot(hdr, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||||
|
// Soft knee: smooth roll-off instead of a hard threshold cut.
|
||||||
|
// Fully-bright passes through; near-threshold tapers to zero.
|
||||||
|
let soft = max(lum - u.threshold, 0.0) / (lum + 0.0001);
|
||||||
|
let contribution = hdr * soft;
|
||||||
|
return vec4<f32>(contribution, 1.0);
|
||||||
|
}
|
||||||
33
assets/shaders/composite.wgsl
Normal file
33
assets/shaders/composite.wgsl
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
||||||
|
|
||||||
|
struct CompositeUniform {
|
||||||
|
bloom_strength: f32,
|
||||||
|
exposure: f32,
|
||||||
|
_pad0: f32,
|
||||||
|
_pad1: f32,
|
||||||
|
};
|
||||||
|
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> u: CompositeUniform;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var scene_tex: texture_2d<f32>;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var scene_samp: sampler;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var bloom_tex: texture_2d<f32>;
|
||||||
|
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var bloom_samp: sampler;
|
||||||
|
|
||||||
|
// ACES Narkowicz fit (5 ops, clamped to [0,1]).
|
||||||
|
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
|
||||||
|
let a = 2.51;
|
||||||
|
let b = 0.03;
|
||||||
|
let c = 2.43;
|
||||||
|
let d = 0.59;
|
||||||
|
let e = 0.14;
|
||||||
|
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3<f32>(0.0), vec3<f32>(1.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
let hdr = textureSample(scene_tex, scene_samp, in.uv).rgb;
|
||||||
|
let bloom = textureSample(bloom_tex, bloom_samp, in.uv).rgb;
|
||||||
|
let combined = hdr + bloom * u.bloom_strength;
|
||||||
|
let mapped = aces_tonemap(combined * u.exposure);
|
||||||
|
return vec4<f32>(mapped, 1.0);
|
||||||
|
}
|
||||||
@ -1,10 +0,0 @@
|
|||||||
#import bevy_sprite::mesh2d_vertex_output::VertexOutput
|
|
||||||
|
|
||||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var tex: texture_2d<f32>;
|
|
||||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var samp: sampler;
|
|
||||||
|
|
||||||
@fragment
|
|
||||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
|
||||||
// in.uv is [0,1]; sample the offscreen image directly (linear sampler upscales).
|
|
||||||
return textureSample(tex, samp, in.uv);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,26 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
/// Bloom pyramid depth (number of bloom textures).
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||||||
|
pub enum BloomQuality {
|
||||||
|
Off, // no bloom, scene-only ACES composite
|
||||||
|
Low, // 1 level: brightpass → composite (soft halo)
|
||||||
|
Medium, // 2 levels: brightpass → 1 down → 1 up → composite
|
||||||
|
#[default]
|
||||||
|
High, // 3 levels: brightpass → 2 down → 2 up → composite (full cinematic)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BloomQuality {
|
||||||
|
pub fn levels(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
BloomQuality::Off => 0,
|
||||||
|
BloomQuality::Low => 1,
|
||||||
|
BloomQuality::Medium => 2,
|
||||||
|
BloomQuality::High => 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// All tunable black-hole parameters. Edited by the egui panel (Task 17),
|
/// All tunable black-hole parameters. Edited by the egui panel (Task 17),
|
||||||
/// mirrored into BlackHoleUniforms each frame (Task 7).
|
/// mirrored into BlackHoleUniforms each frame (Task 7).
|
||||||
#[derive(Resource, Clone)]
|
#[derive(Resource, Clone)]
|
||||||
@ -27,6 +48,12 @@ pub struct BlackHoleParams {
|
|||||||
pub planet_count: u32,
|
pub planet_count: u32,
|
||||||
// Kerr (Phase 2; unused in Phase 1)
|
// Kerr (Phase 2; unused in Phase 1)
|
||||||
pub spin: f32,
|
pub spin: f32,
|
||||||
|
// Quality (Phase 3: cinematic rendering)
|
||||||
|
pub star_aa: bool,
|
||||||
|
pub bloom_threshold: f32,
|
||||||
|
pub bloom_strength: f32,
|
||||||
|
pub exposure: f32,
|
||||||
|
pub bloom_quality: BloomQuality,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BlackHoleParams {
|
impl Default for BlackHoleParams {
|
||||||
@ -48,6 +75,11 @@ impl Default for BlackHoleParams {
|
|||||||
skybox_intensity: 0.0, // procedural stars only by default
|
skybox_intensity: 0.0, // procedural stars only by default
|
||||||
planet_count: 0,
|
planet_count: 0,
|
||||||
spin: 0.0,
|
spin: 0.0,
|
||||||
|
star_aa: if cfg!(target_arch = "wasm32") { false } else { true },
|
||||||
|
bloom_threshold: 1.0,
|
||||||
|
bloom_strength: 0.8,
|
||||||
|
exposure: 1.0,
|
||||||
|
bloom_quality: if cfg!(target_arch = "wasm32") { BloomQuality::Low } else { BloomQuality::High },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,10 @@ pub struct BlackHoleUniforms {
|
|||||||
pub planet_count: u32,
|
pub planet_count: u32,
|
||||||
pub steps: u32,
|
pub steps: u32,
|
||||||
pub spin: f32, // Phase 2: dimensionless Kerr spin χ = a/M ∈ [0,1].
|
pub spin: f32, // Phase 2: dimensionless Kerr spin χ = a/M ∈ [0,1].
|
||||||
|
pub star_aa: u32,
|
||||||
|
pub bloom_threshold: f32,
|
||||||
|
pub bloom_strength: f32,
|
||||||
|
pub exposure: f32,
|
||||||
pub _pad5: f32,
|
pub _pad5: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +74,10 @@ impl Default for BlackHoleUniforms {
|
|||||||
planet_count: 0,
|
planet_count: 0,
|
||||||
steps: 300,
|
steps: 300,
|
||||||
spin: 0.0,
|
spin: 0.0,
|
||||||
|
star_aa: 1,
|
||||||
|
bloom_threshold: 1.0,
|
||||||
|
bloom_strength: 0.8,
|
||||||
|
exposure: 1.0,
|
||||||
_pad5: 0.0,
|
_pad5: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -115,19 +123,86 @@ impl Material2d for BlackHoleMaterial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Samples the sub-resolution offscreen render and blits it fullscreen.
|
/// Uniform for the bright-pass (bloom stage [2]).
|
||||||
/// Bound to a second Camera2d that draws after the offscreen camera.
|
/// Must be a struct (not bare f32) so the Rust binding size matches the
|
||||||
|
/// WGSL `BrightPassUniform` (16 bytes: threshold + 3 pads). A bare f32
|
||||||
|
/// registers a 4-byte min_binding_size, causing a pipeline-layout mismatch.
|
||||||
|
#[derive(Clone, ShaderType, Default)]
|
||||||
|
pub struct BrightPassUniform {
|
||||||
|
pub threshold: f32,
|
||||||
|
pub _pad0: f32,
|
||||||
|
pub _pad1: f32,
|
||||||
|
pub _pad2: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts luminance above a threshold from the HDR offscreen into a
|
||||||
|
/// half-res float texture (bloom stage [2]). Soft-knee, not hard cut.
|
||||||
#[derive(Asset, TypePath, AsBindGroup, Clone)]
|
#[derive(Asset, TypePath, AsBindGroup, Clone)]
|
||||||
pub struct UpscaleMaterial {
|
pub struct BrightPassMaterial {
|
||||||
#[texture(0)]
|
#[uniform(0)]
|
||||||
#[sampler(1)]
|
pub uniform: BrightPassUniform,
|
||||||
|
#[texture(1)]
|
||||||
|
#[sampler(2)]
|
||||||
pub source: Handle<Image>,
|
pub source: Handle<Image>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Material2d for UpscaleMaterial {
|
impl Material2d for BrightPassMaterial {
|
||||||
fn fragment_shader() -> ShaderRef {
|
fn fragment_shader() -> ShaderRef {
|
||||||
"shaders/upscale.wgsl".into()
|
"shaders/brightpass.wgsl".into()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Uniform for one blur pass (bloom stages [3]/[4]).
|
||||||
|
#[derive(Clone, ShaderType)]
|
||||||
|
pub struct BlurUniform {
|
||||||
|
pub mode: u32, // 0 = downsample, 1 = upsample
|
||||||
|
pub texel_size: Vec2,
|
||||||
|
pub blend: f32, // upsample blend factor (ignored for down)
|
||||||
|
pub _pad0: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass of the bloom down/up pyramid. One material instance per pass
|
||||||
|
/// (Bevy binds uniforms once per material, not per entity).
|
||||||
|
#[derive(Asset, TypePath, AsBindGroup, Clone)]
|
||||||
|
pub struct BlurMaterial {
|
||||||
|
#[uniform(0)]
|
||||||
|
pub uniform: BlurUniform,
|
||||||
|
#[texture(1)]
|
||||||
|
#[sampler(2)]
|
||||||
|
pub source: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Material2d for BlurMaterial {
|
||||||
|
fn fragment_shader() -> ShaderRef {
|
||||||
|
"shaders/blur.wgsl".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform for the composite pass (bloom stage [5]).
|
||||||
|
#[derive(Clone, ShaderType)]
|
||||||
|
pub struct CompositeUniform {
|
||||||
|
pub bloom_strength: f32,
|
||||||
|
pub exposure: f32,
|
||||||
|
pub _pad0: f32,
|
||||||
|
pub _pad1: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Final stage: tone-maps the HDR scene + bloom to LDR for the window.
|
||||||
|
/// Replaces UpscaleMaterial. ACES (Narkowicz) tone mapping.
|
||||||
|
#[derive(Asset, TypePath, AsBindGroup, Clone)]
|
||||||
|
pub struct CompositeMaterial {
|
||||||
|
#[uniform(0)]
|
||||||
|
pub uniform: CompositeUniform,
|
||||||
|
#[texture(1)]
|
||||||
|
#[sampler(2)]
|
||||||
|
pub scene: Handle<Image>,
|
||||||
|
#[texture(3)]
|
||||||
|
#[sampler(4)]
|
||||||
|
pub bloom: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Material2d for CompositeMaterial {
|
||||||
|
fn fragment_shader() -> ShaderRef {
|
||||||
|
"shaders/composite.wgsl".into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -49,6 +49,46 @@ pub struct QuadScaleFactor(pub f32, pub f32);
|
|||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct CompositeQuad;
|
pub struct CompositeQuad;
|
||||||
|
|
||||||
|
/// The half-res Image the bright-pass writes into (bloom stage [2]).
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BloomTarget0(pub Handle<Image>);
|
||||||
|
|
||||||
|
/// Camera + quad markers for the bright-pass (for rebuild queries).
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BrightPassCamera;
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BrightPassQuad;
|
||||||
|
|
||||||
|
/// Camera + quad markers for the blur passes (for rebuild queries).
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BlurCamera;
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BlurQuad;
|
||||||
|
|
||||||
|
/// Bloom pyramid textures bloom_1, bloom_2 (bloom_0 is BloomTarget0).
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BloomTarget1(pub Handle<Image>);
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BloomTarget2(pub Handle<Image>);
|
||||||
|
|
||||||
|
/// The final up-sampled bloom texture read by the composite pass.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct BloomFinalTarget(pub Handle<Image>);
|
||||||
|
|
||||||
|
/// Tracks the bloom quality currently applied to the render pipeline.
|
||||||
|
/// When it differs from `params.bloom_quality`, a rebuild is triggered.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct AppliedBloomQuality(pub crate::params::BloomQuality);
|
||||||
|
|
||||||
|
/// Disables bevy_egui's auto-context creation so we can explicitly assign
|
||||||
|
/// `PrimaryEguiContext` to the composite (window) camera. Without this,
|
||||||
|
/// bevy_egui assigns to the first spawned camera — the offscreen camera,
|
||||||
|
/// which renders to an Rgba16Float Image that egui's Rgba8UnormSrgb pipeline
|
||||||
|
/// can't render into.
|
||||||
|
fn disable_egui_auto_context(mut settings: ResMut<bevy_egui::EguiGlobalSettings>) {
|
||||||
|
settings.auto_create_primary_context = false;
|
||||||
|
}
|
||||||
|
|
||||||
pub struct BlackHolePlugin;
|
pub struct BlackHolePlugin;
|
||||||
|
|
||||||
impl Plugin for BlackHolePlugin {
|
impl Plugin for BlackHolePlugin {
|
||||||
@ -57,8 +97,18 @@ impl Plugin for BlackHolePlugin {
|
|||||||
.init_resource::<crate::camera::WantsPointer>()
|
.init_resource::<crate::camera::WantsPointer>()
|
||||||
.init_resource::<crate::params::BlackHoleParams>()
|
.init_resource::<crate::params::BlackHoleParams>()
|
||||||
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
|
.add_plugins(Material2dPlugin::<BlackHoleMaterial>::default())
|
||||||
.add_plugins(Material2dPlugin::<crate::render::material::UpscaleMaterial>::default())
|
.add_plugins(Material2dPlugin::<crate::render::material::CompositeMaterial>::default())
|
||||||
|
.add_plugins(Material2dPlugin::<crate::render::material::BrightPassMaterial>::default())
|
||||||
|
.add_plugins(Material2dPlugin::<crate::render::material::BlurMaterial>::default())
|
||||||
.add_plugins(bevy_egui::EguiPlugin::default())
|
.add_plugins(bevy_egui::EguiPlugin::default())
|
||||||
|
// The bloom pipeline spawns 7 Camera2d entities (offscreen + brightpass
|
||||||
|
// + 4 blur + composite). bevy_egui's auto-context assigns PrimaryEguiContext
|
||||||
|
// to the FIRST Added<Camera> — the offscreen camera, which renders to an
|
||||||
|
// Rgba16Float Image. egui's pipeline expects the window's Rgba8UnormSrgb
|
||||||
|
// surface → format mismatch crash. Disable auto-assignment and explicitly
|
||||||
|
// tag the composite (window) camera with PrimaryEguiContext (see spawn).
|
||||||
|
// Runs in PreStartup, before setup_primary_egui_context_system.
|
||||||
|
.add_systems(bevy::prelude::PreStartup, disable_egui_auto_context)
|
||||||
.add_systems(Startup, spawn_fullscreen_quad)
|
.add_systems(Startup, spawn_fullscreen_quad)
|
||||||
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
|
.add_systems(Startup, crate::scene::planets::spawn_default_planet)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@ -71,6 +121,7 @@ impl Plugin for BlackHolePlugin {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.add_systems(Update, crate::scene::planets::upload_planets)
|
.add_systems(Update, crate::scene::planets::upload_planets)
|
||||||
|
.add_systems(Update, rebuild_bloom)
|
||||||
// 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);
|
||||||
@ -82,7 +133,9 @@ fn spawn_fullscreen_quad(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut meshes: ResMut<Assets<Mesh>>,
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
mut materials: ResMut<Assets<BlackHoleMaterial>>,
|
mut materials: ResMut<Assets<BlackHoleMaterial>>,
|
||||||
mut upscale_materials: ResMut<Assets<crate::render::material::UpscaleMaterial>>,
|
mut composite_materials: ResMut<Assets<crate::render::material::CompositeMaterial>>,
|
||||||
|
mut bp_materials: ResMut<Assets<crate::render::material::BrightPassMaterial>>,
|
||||||
|
mut blur_materials: ResMut<Assets<crate::render::material::BlurMaterial>>,
|
||||||
mut buffers: ResMut<Assets<ShaderBuffer>>,
|
mut buffers: ResMut<Assets<ShaderBuffer>>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
window: Query<&Window>,
|
window: Query<&Window>,
|
||||||
@ -147,18 +200,221 @@ fn spawn_fullscreen_quad(
|
|||||||
RenderLayers::layer(0),
|
RenderLayers::layer(0),
|
||||||
));
|
));
|
||||||
|
|
||||||
// --- Upscale quad (draws offscreen Image to the window) ---
|
let levels = params.bloom_quality.levels();
|
||||||
|
let mut bloom_final_handle: Option<Handle<Image>> = None;
|
||||||
|
|
||||||
|
if levels > 0 {
|
||||||
|
// NOTE: when bloom is enabled the full 3-level pyramid (High) always
|
||||||
|
// runs here. Partial pyramid — Low = brightpass-only, Medium = 1 down +
|
||||||
|
// 1 up — is a future enhancement; for now this is a simple Off vs On
|
||||||
|
// gate. spawn_bloom_pipeline always emits the full pyramid when called.
|
||||||
|
bloom_final_handle = spawn_bloom_pipeline(
|
||||||
|
&mut commands,
|
||||||
|
&mut images,
|
||||||
|
&mut bp_materials,
|
||||||
|
&mut blur_materials,
|
||||||
|
&mut meshes,
|
||||||
|
&offscreen,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the quality tier currently realized in the render graph. Must be
|
||||||
|
// set here (not via init_resource) so it matches the tiered params default
|
||||||
|
// (Off on web, High on desktop) rather than the enum's #[default].
|
||||||
|
commands.insert_resource(AppliedBloomQuality(params.bloom_quality));
|
||||||
|
|
||||||
|
// --- Composite quad (draws HDR scene + bloom to the window, ACES tone-mapped) ---
|
||||||
|
// When bloom is off (BloomQuality::Off), bloom_final_handle is None and we
|
||||||
|
// fall back to a 1x1 black texture: the composite samples black, yielding a
|
||||||
|
// scene-only ACES composite (no bloom contribution).
|
||||||
|
let bloom_handle = bloom_final_handle.unwrap_or_else(|| {
|
||||||
|
// 1x1 black fallback — composite samples black, effectively scene-only ACES.
|
||||||
|
// 8 zero bytes = one Rgba16Float pixel (16 bits/channel × 4).
|
||||||
|
images.add(Image::new_fill(
|
||||||
|
bevy::render::render_resource::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||||
|
bevy::render::render_resource::TextureDimension::D2,
|
||||||
|
&[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
TextureFormat::Rgba16Float,
|
||||||
|
bevy::asset::RenderAssetUsages::default(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
let composite_mat = composite_materials.add(crate::render::material::CompositeMaterial {
|
||||||
|
uniform: crate::render::material::CompositeUniform {
|
||||||
|
bloom_strength: 0.8, exposure: 1.0, _pad0: 0.0, _pad1: 0.0,
|
||||||
|
},
|
||||||
|
scene: offscreen.clone(),
|
||||||
|
bloom: bloom_handle,
|
||||||
|
});
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||||
MeshMaterial2d(upscale_materials.add(crate::render::material::UpscaleMaterial {
|
MeshMaterial2d(composite_mat),
|
||||||
source: offscreen.clone(),
|
|
||||||
})),
|
|
||||||
Transform::default().with_scale(Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0)),
|
Transform::default().with_scale(Vec3::new(win.width() / 2.0, win.height() / 2.0, 1.0)),
|
||||||
UpscaleQuad,
|
UpscaleQuad,
|
||||||
CompositeQuad,
|
CompositeQuad,
|
||||||
RenderLayers::layer(1),
|
RenderLayers::layer(1),
|
||||||
));
|
));
|
||||||
commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, RenderLayers::layer(1)));
|
// The composite camera renders to the window (Rgba8UnormSrgb surface).
|
||||||
|
// PrimaryEguiContext tells bevy_egui to render the UI onto THIS camera,
|
||||||
|
// not the offscreen/bloom cameras (which render to Rgba16Float Images).
|
||||||
|
commands.spawn((Camera2d, Msaa::Off, UpscaleCamera, Nudgable, bevy_egui::PrimaryEguiContext, RenderLayers::layer(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the bloom pipeline (brightpass + blur pyramid + bloom_final).
|
||||||
|
/// Called at startup and when bloom_quality changes at runtime.
|
||||||
|
///
|
||||||
|
/// Pragmatic scope: this always emits the full 3-level pyramid (High) when
|
||||||
|
/// called. Partial pyramids — Low (brightpass-only) and Medium (1 down + 1 up)
|
||||||
|
/// — are a future enhancement; the caller simply does not invoke this for
|
||||||
|
/// `BloomQuality::Off`. The `levels`-driven branching is intentionally absent.
|
||||||
|
///
|
||||||
|
/// Returns the `bloom_final` handle (the half-res texture the composite pass
|
||||||
|
/// samples). Caller must fall back to a 1x1 black texture when it returns None.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_bloom_pipeline(
|
||||||
|
commands: &mut Commands,
|
||||||
|
images: &mut Assets<Image>,
|
||||||
|
bp_materials: &mut Assets<crate::render::material::BrightPassMaterial>,
|
||||||
|
blur_materials: &mut Assets<crate::render::material::BlurMaterial>,
|
||||||
|
meshes: &mut Assets<Mesh>,
|
||||||
|
offscreen: &Handle<Image>,
|
||||||
|
w: u32,
|
||||||
|
h: u32,
|
||||||
|
) -> Option<Handle<Image>> {
|
||||||
|
// --- Bright-pass (bloom stage [2]): half-res float target ---
|
||||||
|
let bw = ((w as f32 * 0.5) as u32).max(1);
|
||||||
|
let bh = ((h as f32 * 0.5) as u32).max(1);
|
||||||
|
let bloom0 = images.add(Image::new_target_texture(
|
||||||
|
bw, bh, TextureFormat::Rgba16Float, None,
|
||||||
|
));
|
||||||
|
commands.spawn(BloomTarget0(bloom0.clone()));
|
||||||
|
commands.spawn((
|
||||||
|
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||||
|
MeshMaterial2d(bp_materials.add(crate::render::material::BrightPassMaterial {
|
||||||
|
uniform: crate::render::material::BrightPassUniform {
|
||||||
|
threshold: 1.0, _pad0: 0.0, _pad1: 0.0, _pad2: 0.0,
|
||||||
|
},
|
||||||
|
source: offscreen.clone(),
|
||||||
|
})),
|
||||||
|
Transform::default().with_scale(Vec3::new(bw as f32 / 2.0, bh as f32 / 2.0, 1.0)),
|
||||||
|
BrightPassQuad,
|
||||||
|
QuadScaleFactor(0.5, 0.5),
|
||||||
|
RenderLayers::layer(2),
|
||||||
|
));
|
||||||
|
commands.spawn((
|
||||||
|
Camera2d,
|
||||||
|
Camera {
|
||||||
|
order: -19,
|
||||||
|
clear_color: ClearColorConfig::Custom(Color::srgb(0.0, 0.0, 0.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
RenderTarget::Image(bloom0.clone().into()),
|
||||||
|
Msaa::Off,
|
||||||
|
BrightPassCamera,
|
||||||
|
Nudgable,
|
||||||
|
RenderLayers::layer(2),
|
||||||
|
));
|
||||||
|
|
||||||
|
// --- Blur pyramid (bloom stages [3]/[4]): bloom_1, bloom_2 + down/up passes ---
|
||||||
|
let b1w = ((w as f32 * 0.25) as u32).max(1);
|
||||||
|
let b1h = ((h as f32 * 0.25) as u32).max(1);
|
||||||
|
let b2w = ((w as f32 * 0.125) as u32).max(1);
|
||||||
|
let b2h = ((h as f32 * 0.125) as u32).max(1);
|
||||||
|
let bloom1 = images.add(Image::new_target_texture(
|
||||||
|
b1w, b1h, TextureFormat::Rgba16Float, None,
|
||||||
|
));
|
||||||
|
let bloom2 = images.add(Image::new_target_texture(
|
||||||
|
b2w, b2h, TextureFormat::Rgba16Float, None,
|
||||||
|
));
|
||||||
|
commands.spawn(BloomTarget1(bloom1.clone()));
|
||||||
|
commands.spawn(BloomTarget2(bloom2.clone()));
|
||||||
|
|
||||||
|
// Down pass 0→1: samples bloom0 (half-res), writes bloom1 (quarter-res).
|
||||||
|
let down01_texel = Vec2::new(1.0 / bw as f32, 1.0 / bh as f32);
|
||||||
|
commands.spawn((
|
||||||
|
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||||
|
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
|
||||||
|
uniform: crate::render::material::BlurUniform {
|
||||||
|
mode: 0, texel_size: down01_texel, blend: 0.0, _pad0: 0.0,
|
||||||
|
},
|
||||||
|
source: bloom0.clone(),
|
||||||
|
})),
|
||||||
|
Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)),
|
||||||
|
BlurQuad,
|
||||||
|
QuadScaleFactor(0.25, 0.25),
|
||||||
|
RenderLayers::layer(3),
|
||||||
|
));
|
||||||
|
// Down pass 1→2: samples bloom1 (quarter), writes bloom2 (eighth).
|
||||||
|
let down12_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32);
|
||||||
|
commands.spawn((
|
||||||
|
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||||
|
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
|
||||||
|
uniform: crate::render::material::BlurUniform {
|
||||||
|
mode: 0, texel_size: down12_texel, blend: 0.0, _pad0: 0.0,
|
||||||
|
},
|
||||||
|
source: bloom1.clone(),
|
||||||
|
})),
|
||||||
|
Transform::default().with_scale(Vec3::new(b2w as f32 / 2.0, b2h as f32 / 2.0, 1.0)),
|
||||||
|
BlurQuad,
|
||||||
|
QuadScaleFactor(0.125, 0.125),
|
||||||
|
RenderLayers::layer(4),
|
||||||
|
));
|
||||||
|
// Up pass 2→1: samples bloom2 (eighth), writes bloom1 (quarter).
|
||||||
|
let up21_texel = Vec2::new(1.0 / b2w as f32, 1.0 / b2h as f32);
|
||||||
|
commands.spawn((
|
||||||
|
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||||
|
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
|
||||||
|
uniform: crate::render::material::BlurUniform {
|
||||||
|
mode: 1, texel_size: up21_texel, blend: 0.6, _pad0: 0.0,
|
||||||
|
},
|
||||||
|
source: bloom2.clone(),
|
||||||
|
})),
|
||||||
|
Transform::default().with_scale(Vec3::new(b1w as f32 / 2.0, b1h as f32 / 2.0, 1.0)),
|
||||||
|
BlurQuad,
|
||||||
|
QuadScaleFactor(0.25, 0.25),
|
||||||
|
RenderLayers::layer(5),
|
||||||
|
));
|
||||||
|
// Up pass 1→0: samples bloom1 (quarter), writes bloom_final (half).
|
||||||
|
let bfw = bw;
|
||||||
|
let bfh = bh;
|
||||||
|
let bloom_final = images.add(Image::new_target_texture(
|
||||||
|
bfw, bfh, TextureFormat::Rgba16Float, None,
|
||||||
|
));
|
||||||
|
commands.spawn(BloomFinalTarget(bloom_final.clone()));
|
||||||
|
let up10_texel = Vec2::new(1.0 / b1w as f32, 1.0 / b1h as f32);
|
||||||
|
commands.spawn((
|
||||||
|
Mesh2d(meshes.add(Rectangle::new(2.0, 2.0))),
|
||||||
|
MeshMaterial2d(blur_materials.add(crate::render::material::BlurMaterial {
|
||||||
|
uniform: crate::render::material::BlurUniform {
|
||||||
|
mode: 1, texel_size: up10_texel, blend: 0.8, _pad0: 0.0,
|
||||||
|
},
|
||||||
|
source: bloom1.clone(),
|
||||||
|
})),
|
||||||
|
Transform::default().with_scale(Vec3::new(bfw as f32 / 2.0, bfh as f32 / 2.0, 1.0)),
|
||||||
|
BlurQuad,
|
||||||
|
QuadScaleFactor(0.5, 0.5),
|
||||||
|
RenderLayers::layer(6),
|
||||||
|
));
|
||||||
|
// Cameras: down01=-18, down12=-17, up21=-16, up10=-15.
|
||||||
|
commands.spawn((
|
||||||
|
Camera2d, Camera { order: -18, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
|
||||||
|
RenderTarget::Image(bloom1.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(3),
|
||||||
|
));
|
||||||
|
commands.spawn((
|
||||||
|
Camera2d, Camera { order: -17, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
|
||||||
|
RenderTarget::Image(bloom2.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(4),
|
||||||
|
));
|
||||||
|
commands.spawn((
|
||||||
|
Camera2d, Camera { order: -16, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
|
||||||
|
RenderTarget::Image(bloom1.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(5),
|
||||||
|
));
|
||||||
|
commands.spawn((
|
||||||
|
Camera2d, Camera { order: -15, clear_color: ClearColorConfig::Custom(Color::srgb(0.0,0.0,0.0)), ..default() },
|
||||||
|
RenderTarget::Image(bloom_final.clone().into()), Msaa::Off, BlurCamera, Nudgable, RenderLayers::layer(6),
|
||||||
|
));
|
||||||
|
|
||||||
|
Some(bloom_final.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recreate the offscreen Image and rescale both quads on window resize,
|
/// Recreate the offscreen Image and rescale both quads on window resize,
|
||||||
@ -168,6 +424,10 @@ fn resize_offscreen(
|
|||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
params: Res<crate::params::BlackHoleParams>,
|
params: Res<crate::params::BlackHoleParams>,
|
||||||
target: Query<&OffscreenTarget>,
|
target: Query<&OffscreenTarget>,
|
||||||
|
bloom0: Query<&BloomTarget0>,
|
||||||
|
bloom1: Query<&BloomTarget1>,
|
||||||
|
bloom2: Query<&BloomTarget2>,
|
||||||
|
bloom_final: Query<&BloomFinalTarget>,
|
||||||
window: Query<&Window>,
|
window: Query<&Window>,
|
||||||
mut resized: MessageReader<bevy::window::WindowResized>,
|
mut resized: MessageReader<bevy::window::WindowResized>,
|
||||||
// Both queries borrow `&mut Transform`. Bevy's conflict checker does not
|
// Both queries borrow `&mut Transform`. Bevy's conflict checker does not
|
||||||
@ -194,6 +454,25 @@ fn resize_offscreen(
|
|||||||
// insert is harmless — it'll succeed on the next WindowResized.
|
// insert is harmless — it'll succeed on the next WindowResized.
|
||||||
let _ = images.insert(handle.0.id(), img);
|
let _ = images.insert(handle.0.id(), img);
|
||||||
}
|
}
|
||||||
|
// Bloom pyramid (queries return empty when bloom is off).
|
||||||
|
let bw = ((w as f32 * 0.5) as u32).max(1);
|
||||||
|
let bh = ((h as f32 * 0.5) as u32).max(1);
|
||||||
|
let b1w = ((w as f32 * 0.25) as u32).max(1);
|
||||||
|
let b1h = ((h as f32 * 0.25) as u32).max(1);
|
||||||
|
let b2w = ((w as f32 * 0.125) as u32).max(1);
|
||||||
|
let b2h = ((h as f32 * 0.125) as u32).max(1);
|
||||||
|
if let Ok(t) = bloom0.single() {
|
||||||
|
let _ = images.insert(t.0.id(), Image::new_target_texture(bw, bh, TextureFormat::Rgba16Float, None));
|
||||||
|
}
|
||||||
|
if let Ok(t) = bloom1.single() {
|
||||||
|
let _ = images.insert(t.0.id(), Image::new_target_texture(b1w, b1h, TextureFormat::Rgba16Float, None));
|
||||||
|
}
|
||||||
|
if let Ok(t) = bloom2.single() {
|
||||||
|
let _ = images.insert(t.0.id(), Image::new_target_texture(b2w, b2h, TextureFormat::Rgba16Float, None));
|
||||||
|
}
|
||||||
|
if let Ok(t) = bloom_final.single() {
|
||||||
|
let _ = images.insert(t.0.id(), Image::new_target_texture(bw, bh, TextureFormat::Rgba16Float, None));
|
||||||
|
}
|
||||||
// Rescale offscreen + bloom quads against the offscreen resolution.
|
// Rescale offscreen + bloom quads against the offscreen resolution.
|
||||||
for (mut t, f) in &mut quads.p0() {
|
for (mut t, f) in &mut quads.p0() {
|
||||||
t.scale = Vec3::new(w as f32 * f.0 / 2.0, h as f32 * f.1 / 2.0, 1.0);
|
t.scale = Vec3::new(w as f32 * f.0 / 2.0, h as f32 * f.1 / 2.0, 1.0);
|
||||||
@ -223,12 +502,88 @@ fn nudge_camera(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Detects a bloom_quality change and rebuilds the bloom pipeline.
|
||||||
|
/// Heavy (despawns and respawns all bloom entities + their cameras/targets)
|
||||||
|
/// but only fires when the user changes the dropdown in the Quality panel.
|
||||||
|
///
|
||||||
|
/// `applied` is read as `Res` (immutable borrow) and re-written via
|
||||||
|
/// `commands.insert_resource` (a queued command applied later in the frame),
|
||||||
|
/// so the read/write do not conflict in Bevy's system parameter borrow checker.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
fn rebuild_bloom(
|
||||||
|
params: Res<crate::params::BlackHoleParams>,
|
||||||
|
applied: Res<AppliedBloomQuality>,
|
||||||
|
mut commands: Commands,
|
||||||
|
bloom_entities: Query<Entity, Or<(
|
||||||
|
With<BrightPassCamera>, With<BlurCamera>,
|
||||||
|
With<BrightPassQuad>, With<BlurQuad>,
|
||||||
|
)>>,
|
||||||
|
bloom_targets: Query<Entity, Or<(
|
||||||
|
With<BloomTarget0>, With<BloomTarget1>,
|
||||||
|
With<BloomTarget2>, With<BloomFinalTarget>,
|
||||||
|
)>>,
|
||||||
|
mut composite_materials: ResMut<Assets<crate::render::material::CompositeMaterial>>,
|
||||||
|
offscreen_target: Query<&OffscreenTarget>,
|
||||||
|
window: Query<&Window>,
|
||||||
|
mut images: ResMut<Assets<Image>>,
|
||||||
|
mut bp_materials: ResMut<Assets<crate::render::material::BrightPassMaterial>>,
|
||||||
|
mut blur_materials: ResMut<Assets<crate::render::material::BlurMaterial>>,
|
||||||
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
|
) {
|
||||||
|
if params.bloom_quality == applied.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Despawn all bloom entities (cameras + quads).
|
||||||
|
for e in bloom_entities.iter() {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
// Despawn all bloom target markers (they are spawned as bare entities).
|
||||||
|
for e in bloom_targets.iter() {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
// Re-spawn at the new quality (or not at all, if Off).
|
||||||
|
let Ok(win) = window.single() else { return; };
|
||||||
|
let scale = params.render_scale.clamp(MIN_RENDER_SCALE, 1.0);
|
||||||
|
let w = ((win.width() * scale) as u32).max(1);
|
||||||
|
let h = ((win.height() * scale) as u32).max(1);
|
||||||
|
let Ok(offscreen_t) = offscreen_target.single() else { return; };
|
||||||
|
let offscreen = offscreen_t.0.clone();
|
||||||
|
let new_bloom = spawn_bloom_pipeline(
|
||||||
|
&mut commands,
|
||||||
|
&mut images,
|
||||||
|
&mut bp_materials,
|
||||||
|
&mut blur_materials,
|
||||||
|
&mut meshes,
|
||||||
|
&offscreen,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
);
|
||||||
|
// Update the composite material's bloom handle. When the new quality is
|
||||||
|
// Off, spawn_bloom_pipeline returns None and we fall back to a 1x1 black
|
||||||
|
// texture so the composite samples black (scene-only ACES).
|
||||||
|
let bloom_handle = new_bloom.unwrap_or_else(|| {
|
||||||
|
images.add(Image::new_fill(
|
||||||
|
bevy::render::render_resource::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||||
|
bevy::render::render_resource::TextureDimension::D2,
|
||||||
|
&[0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
TextureFormat::Rgba16Float,
|
||||||
|
bevy::asset::RenderAssetUsages::default(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
for (_, mat) in composite_materials.iter_mut() {
|
||||||
|
mat.bloom = bloom_handle.clone();
|
||||||
|
}
|
||||||
|
commands.insert_resource(AppliedBloomQuality(params.bloom_quality));
|
||||||
|
}
|
||||||
|
|
||||||
fn mirror_params(
|
fn mirror_params(
|
||||||
camera: Res<crate::camera::OrbitCamera>,
|
camera: Res<crate::camera::OrbitCamera>,
|
||||||
params: Res<crate::params::BlackHoleParams>,
|
params: Res<crate::params::BlackHoleParams>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
window: Query<&Window>,
|
window: Query<&Window>,
|
||||||
mut materials: ResMut<Assets<BlackHoleMaterial>>,
|
mut materials: ResMut<Assets<BlackHoleMaterial>>,
|
||||||
|
mut brightpass_materials: ResMut<Assets<crate::render::material::BrightPassMaterial>>,
|
||||||
|
mut composite_materials: ResMut<Assets<crate::render::material::CompositeMaterial>>,
|
||||||
) {
|
) {
|
||||||
let win = match window.single() {
|
let win = match window.single() {
|
||||||
Ok(w) => w,
|
Ok(w) => w,
|
||||||
@ -261,5 +616,18 @@ fn mirror_params(
|
|||||||
u.planet_count = params.planet_count;
|
u.planet_count = params.planet_count;
|
||||||
u.steps = params.steps;
|
u.steps = params.steps;
|
||||||
u.spin = params.spin;
|
u.spin = params.spin;
|
||||||
|
u.star_aa = params.star_aa as u32;
|
||||||
|
u.bloom_threshold = params.bloom_threshold;
|
||||||
|
u.bloom_strength = params.bloom_strength;
|
||||||
|
u.exposure = params.exposure;
|
||||||
|
}
|
||||||
|
// Update brightpass threshold (live-tunable).
|
||||||
|
for (_, mat) in brightpass_materials.iter_mut() {
|
||||||
|
mat.uniform.threshold = params.bloom_threshold;
|
||||||
|
}
|
||||||
|
// Update composite material uniforms (bloom strength + exposure live-tunable).
|
||||||
|
for (_, mat) in composite_materials.iter_mut() {
|
||||||
|
mat.uniform.bloom_strength = params.bloom_strength;
|
||||||
|
mat.uniform.exposure = params.exposure;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/ui.rs
21
src/ui.rs
@ -52,6 +52,27 @@ pub fn ui_system(
|
|||||||
ui.checkbox(&mut params.grid_enabled, "Enabled");
|
ui.checkbox(&mut params.grid_enabled, "Enabled");
|
||||||
ui.add_enabled(params.grid_enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
|
ui.add_enabled(params.grid_enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density"));
|
||||||
});
|
});
|
||||||
|
egui::CollapsingHeader::new("Quality")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
use crate::params::BloomQuality;
|
||||||
|
let mut q = params.bloom_quality;
|
||||||
|
egui::ComboBox::from_label("Bloom quality")
|
||||||
|
.selected_text(format!("{:?}", q))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut q, BloomQuality::Off, "Off");
|
||||||
|
ui.selectable_value(&mut q, BloomQuality::Low, "Low");
|
||||||
|
ui.selectable_value(&mut q, BloomQuality::Medium, "Medium");
|
||||||
|
ui.selectable_value(&mut q, BloomQuality::High, "High");
|
||||||
|
});
|
||||||
|
params.bloom_quality = q;
|
||||||
|
ui.add_enabled(q != BloomQuality::Off, egui::Slider::new(&mut params.bloom_threshold, 0.0..=3.0).text("Bloom threshold"));
|
||||||
|
ui.add_enabled(q != BloomQuality::Off, egui::Slider::new(&mut params.bloom_strength, 0.0..=2.0).text("Bloom strength"));
|
||||||
|
ui.add(egui::Slider::new(&mut params.exposure, 0.5..=3.0).text("Exposure"));
|
||||||
|
ui.add(egui::Slider::new(&mut params.render_scale, 0.25..=1.0).text("Resolution scale"));
|
||||||
|
ui.checkbox(&mut params.star_aa, "Anti-aliased stars");
|
||||||
|
ui.label("MSAA is decorative on a fullscreen shader (no geometry edges to sample).");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// egui captures pointer when the cursor is over a window or being interacted with.
|
// egui captures pointer when the cursor is over a window or being interacted with.
|
||||||
wants.0 = ctx.egui_wants_pointer_input();
|
wants.0 = ctx.egui_wants_pointer_input();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user