diff --git a/crates/misaligned-assets/src/rack.rs b/crates/misaligned-assets/src/rack.rs index 8819940a..aa06748f 100644 --- a/crates/misaligned-assets/src/rack.rs +++ b/crates/misaligned-assets/src/rack.rs @@ -15,6 +15,8 @@ //! by only one binary, hence the file-level dead_code allowance. #![allow(dead_code)] +use bevy::asset::RenderAssetUsages; +use bevy::mesh::PrimitiveTopology; use bevy::prelude::*; use crate::palette::{AMBER, AMBER_DIM, BONE, CRIMSON, GUNMETAL_DARK, NEAR_BLACK, SIGNAL, scaled}; @@ -939,37 +941,76 @@ fn spawn_exposure_drift( root: Entity, ) { let fleck = materials.add(exposure_fleck_material()); - let fine_grain = meshes.add(Cuboid::new(0.007, 0.002, 0.013)); - let coarse_grain = meshes.add(Cuboid::new(0.011, 0.003, 0.018)); - let n = ((exposure * 30.0).ceil() as usize).clamp(8, 220); - // Dust forms an offset elliptical bank at the chassis foot, not an - // annulus. The whole patch remains inside one tile at the presentation - // cap; amount reads mainly as denser contamination. + // Hundreds to more than a thousand grains stay one render entity and one + // draw-batch-sized mesh. Making every pinprick an ECS entity would turn + // visual density into simulation overhead and still leave the grains + // looking like cuboids. + commands.spawn(( + Mesh3d(meshes.add(build_exposure_drift_mesh(exposure))), + MeshMaterial3d(fleck), + ChildOf(root), + )); +} + +const EXPOSURE_GRAINS_PER_TOKEN: f32 = 160.0; +const EXPOSURE_GRAIN_MIN: usize = 40; +const EXPOSURE_GRAIN_MAX: usize = 1_400; + +fn exposure_grain_count(exposure: f32) -> usize { + ((exposure.max(0.0) * EXPOSURE_GRAINS_PER_TOKEN).ceil() as usize) + .clamp(EXPOSURE_GRAIN_MIN, EXPOSURE_GRAIN_MAX) +} + +/// One compact triangle soup: every grain is a tiny, irregular floor-facing +/// flake. At gameplay scale most resolve to one or two pixels rather than a +/// miniature brick, while hundreds together carry the amount. +fn build_exposure_drift_mesh(exposure: f32) -> Mesh { + let count = exposure_grain_count(exposure); let reach = exposure_drift_outer_radius(exposure); + let mut positions = Vec::with_capacity(count * 3); + let mut normals = Vec::with_capacity(count * 3); + + for i in 0..count { + let center = exposure_drift_position(i, reach); + let rotation = exposure_hash01(i, 5) * std::f32::consts::TAU; + let forward = Vec2::new(rotation.cos(), rotation.sin()); + let side = Vec2::new(-forward.y, forward.x); + let half_width = 0.000_7 + exposure_hash01(i, 6) * 0.000_9; + let half_length = 0.001_4 + exposure_hash01(i, 7) * 0.001_6; + let skew = (exposure_hash01(i, 8) - 0.5) * half_width; + let y = 0.002_5 + exposure_hash01(i, 9) * 0.001_2; + + let back_left = center - forward * half_length - side * half_width; + let back_right = center - forward * half_length + side * half_width; + let point = center + forward * half_length + side * skew; + // Winding faces +Y. The slight asymmetry avoids a repeated diamond or + // rectangular chip silhouette at close zoom. + positions.extend([ + [back_left.x, y, back_left.y], + [back_right.x, y, back_right.y], + [point.x, y, point.y], + ]); + normals.extend([[0.0, 1.0, 0.0]; 3]); + } + + Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, + ) + .with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, positions) + .with_inserted_attribute(Mesh::ATTRIBUTE_NORMAL, normals) +} + +fn exposure_drift_position(index: usize, reach: f32) -> Vec2 { // BIAS is the airflow-shadow direction, toward front-right. - const BIAS: f32 = 0.7; // radians, toward front-right + const BIAS: f32 = 0.7; let downwind = Vec2::new(BIAS.cos(), BIAS.sin()); let crosswind = Vec2::new(-downwind.y, downwind.x); - for i in 0..n { - let (theta, radial_frac) = exposure_drift_sample(i); - let r = radial_frac.sqrt(); - let along = reach * (0.46 + theta.cos() * r * 0.54); - let across = reach * theta.sin() * r * 0.42; - let position = downwind * along + crosswind * across; - let grain = if exposure_hash01(i, 4) < 1.0 / 7.0 { - coarse_grain.clone() - } else { - fine_grain.clone() - }; - commands.spawn(( - Mesh3d(grain), - MeshMaterial3d(fleck.clone()), - Transform::from_translation(Vec3::new(position.x, 0.003, position.y)).with_rotation( - Quat::from_rotation_y(exposure_hash01(i, 5) * std::f32::consts::TAU), - ), - ChildOf(root), - )); - } + let (theta, radial_frac) = exposure_drift_sample(index); + let r = radial_frac.sqrt(); + let along = reach * (0.46 + theta.cos() * r * 0.54); + let across = reach * theta.sin() * r * 0.42; + downwind * along + crosswind * across } /// Stratify angular coverage, then independently jitter angle and radius. @@ -1216,4 +1257,14 @@ mod tests { ); } } + + #[test] + fn exposure_uses_many_tiny_grains_in_one_mesh() { + assert_eq!(exposure_grain_count(0.1), EXPOSURE_GRAIN_MIN); + assert!(exposure_grain_count(6.0) >= 900); + assert_eq!(exposure_grain_count(100.0), EXPOSURE_GRAIN_MAX); + + let mesh = build_exposure_drift_mesh(6.0); + assert_eq!(mesh.count_vertices(), exposure_grain_count(6.0) * 3); + } } diff --git a/wiki/log/2026-07-10-exposure-fine-grains.md b/wiki/log/2026-07-10-exposure-fine-grains.md new file mode 100644 index 00000000..5779ce8e --- /dev/null +++ b/wiki/log/2026-07-10-exposure-fine-grains.md @@ -0,0 +1,22 @@ +# Make Exposure genuinely fine-grained + +``` +Type: log +``` + +- **Intent:** make the residue read as a large quantity of tiny dust grains, + not a modest quantity of small geometric pieces. +- **Changed:** replaced per-grain cuboid entities with one deterministic + triangle-soup mesh per deposit. Exposure now produces 160 pinprick grains per + visible token, capped at 1,400, with independently varied pinprick-scale + width, length, rotation, and floor height. The irregular downwind bank and + non-spiral coordinate split remain intact. +- **Design/spec impact:** tightened the particulate visual criterion: quantity + comes from many more specks, not larger chunks. +- **Checks:** asset/Bevy tests and clippy passed; inspected both a saturated + close material capture and the played Thought-flow scene. At close range the + bank is hundreds of one-to-three-pixel flecks rather than red chips, while + the wider frame compresses them into a dense particulate stain. +- **Defense:** implements `wiki/mechanics/machine-work.md` clause “Exposure is + particulate residue”; one batched mesh permits actual dust-scale density + without making presentation grains into per-entity simulation overhead. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 869045ff..b6ccf2cf 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -366,6 +366,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-07-10-eyes-camera-exposure.md](2026-07-10-eyes-camera-exposure.md) +## 2026-07-10 - Make Exposure genuinely fine-grained + +- Intent: (see session log) +- Log: [wiki/log/2026-07-10-exposure-fine-grains.md](2026-07-10-exposure-fine-grains.md) + ## 2026-07-10 - Exposure dust: tight source and immediate LIE scrub - Intent: (see session log) diff --git a/wiki/mechanics/machine-work.md b/wiki/mechanics/machine-work.md index 6ace7cb6..741ad2a8 100644 --- a/wiki/mechanics/machine-work.md +++ b/wiki/mechanics/machine-work.md @@ -660,7 +660,9 @@ that can ride wires; exposure is contamination that cannot. The family kit is: accumulation, never a decorative halo or periodic spiral. Particle radius, position, size, and rotation use independent deterministic dimensions so a stable paused frame still looks irregular (bias direction and thinning are - [TUNE]). It absorbs light rather than emitting it — the one + [TUNE]). Individual grains are pinprick-scale irregular flecks, not miniature + cuboids; saturation reads through hundreds of additional specks rather than + larger pieces. It absorbs light rather than emitting it — the one near-unlit material in a world of signals. Its physics and carrier roles stand: pulled by concealment wells (a vacuum draw on the dust), transfers onto people as attention, and materializes at the Office as filings