Ports the lighting settings into upstream's single settings registry and the lighting plugins into game_plugins.rs. 1108 world+formats tests green; world scenes (inn, Northshire dusk, Stormwind) render clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1370 lines
63 KiB
Rust
1370 lines
63 KiB
Rust
//! The render-world half of the B1 retained pass (see `mod.rs`; decision 1429): extraction of
|
|
//! the published cell set, per-cell GPU assembly (texture-array classes + the item→layer
|
|
//! table), the pipeline family, and the draw node between the main opaque and transparent
|
|
//! passes.
|
|
//!
|
|
//! Assembly happens where each fact lives: the MAIN world bakes geometry (it owns the
|
|
//! submeshes) but cannot know texture dims/format (BLP images are `RENDER_WORLD`-only), so
|
|
//! classing into `texture_2d_array`s happens HERE, once each member's `GpuImage` is resident.
|
|
//! A cell whose textures aren't all loaded yet simply isn't drawn that frame (the entity path
|
|
//! streams batches in piecewise; cell-granular appearance is the same arrival class, mostly
|
|
//! under the load cover).
|
|
//!
|
|
//! **The arrays are ONE SHARED POOL, not per-cell (B3, decision 1432)** — `pool.rs` owns the
|
|
//! design note (the two driver taxes 1431's `sample` caught, and how dedup + drain-once +
|
|
//! sibling growth remove them structurally). Here, a re-bake costs a record table and a few
|
|
//! bind groups, never a texture.
|
|
|
|
use bevy::camera::primitives::Aabb;
|
|
use bevy::core_pipeline::oit::OrderIndependentTransparencySettingsOffset;
|
|
use bevy::ecs::query::QueryItem;
|
|
use bevy::image::Image;
|
|
use bevy::mesh::VertexBufferLayout;
|
|
use bevy::pbr::{
|
|
MeshPipeline, MeshPipelineViewLayoutKey, MeshViewBindGroup, ViewEnvironmentMapUniformOffset,
|
|
ViewFogUniformOffset, ViewLightProbesUniformOffset, ViewLightsUniformOffset,
|
|
ViewScreenSpaceReflectionsUniformOffset,
|
|
};
|
|
use bevy::platform::collections::HashMap;
|
|
use bevy::prelude::*;
|
|
use bevy::render::diagnostic::RecordDiagnostics;
|
|
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
|
|
use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin};
|
|
use bevy::render::mesh::allocator::MeshAllocator;
|
|
use bevy::render::mesh::RenderMesh;
|
|
use bevy::render::render_asset::RenderAssets;
|
|
use bevy::render::render_graph::{
|
|
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
|
|
};
|
|
use bevy::render::render_resource::binding_types::{
|
|
sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer_sized,
|
|
};
|
|
use bevy::render::render_resource::*;
|
|
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
|
|
use bevy::render::texture::GpuImage;
|
|
use bevy::render::view::{ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniformOffset};
|
|
use bevy::render::{Render, RenderSystems};
|
|
use bevy::shader::ShaderDefVal;
|
|
use std::ops::Range;
|
|
|
|
use bevy::core_pipeline::core_3d::graph::{Core3d, Node3d};
|
|
use bevy::core_pipeline::core_3d::CORE_3D_DEPTH_FORMAT;
|
|
|
|
/// One baked item's draw facts (index-parallel with the bake order; the vertex word's low bits
|
|
/// carry this item's index, which the record table resolves to an array layer + the WMO
|
|
/// per-item record).
|
|
#[derive(Clone)]
|
|
pub(crate) struct GxItemDraw {
|
|
pub index_range: Range<u32>,
|
|
pub texture: Option<AssetId<Image>>,
|
|
pub cutout: bool,
|
|
pub two_sided: bool,
|
|
#[allow(dead_code)] // bake-side bookkeeping; the node draws by index range alone
|
|
pub vertex_range: Range<u32>,
|
|
/// The range-selection key (`None` on cell items — always drawn): a WMO item's GROUP,
|
|
/// or a prop item's referrer-SET index (B4). A run never crosses a selection boundary,
|
|
/// so the per-frame verdict selects whole runs.
|
|
pub group: Option<u16>,
|
|
/// The authored batch order (the coplanar-MOBA clip-z nudge; 0 on cell items).
|
|
pub order: u16,
|
|
/// The MOMT SIDN night-glow colour (gamma bytes; zero on cell items).
|
|
pub sidn: [u8; 3],
|
|
/// The interior prop's SH-probe slot (B4; 0 elsewhere — read only under the word's
|
|
/// INTERIOR-without-WMO lane). Rides the record table's w column, bits 1..14.
|
|
pub slot: u16,
|
|
/// MONKEY (ext-class night law): this is an EXTERIOR-class WMO batch at BUILDING scale — the
|
|
/// record table's bit 27 (see [`RECORD_EXT_NIGHT_BIT`]). False on cells, props and interior
|
|
/// batches.
|
|
pub ext_night: bool,
|
|
/// MONKEY (enclosed day floor): this is an INTERIOR-class WMO batch whose group sits inside a
|
|
/// building-scale shell — the record table's bit 28 (see [`RECORD_ENCLOSED_BIT`]). False on
|
|
/// cells, props, exterior batches, and every group of a dungeon (which authors no shell).
|
|
pub enclosed: bool,
|
|
}
|
|
|
|
/// One baked cell (or WMO region), published by the main-world flush.
|
|
#[derive(Clone)]
|
|
pub(crate) struct GxCellDraw {
|
|
pub mesh: Handle<Mesh>,
|
|
/// The recentring origin (0974's precision split): shader world = vertex + origin.
|
|
pub origin: Vec3,
|
|
/// Mesh-local bound (recentred); world bound = origin + this.
|
|
pub aabb: Aabb,
|
|
pub draws: Vec<GxItemDraw>,
|
|
/// The exile kill bitmap (B2, 1431): bit *i* set ⇒ item *i* is punched out of the
|
|
/// retained draw (its placement is feathering as ordinary entities, or fully faded).
|
|
/// Rebuilt in place by the main-world scan; all-zero on WMO regions.
|
|
pub killed: Vec<u64>,
|
|
/// Bumped by the scan on every bitmap change — the render side syncs the record table's
|
|
/// kill column when it sees a revision it hasn't applied.
|
|
pub killed_rev: u32,
|
|
/// Per-selection-grain mesh-local bounds (empty for cells): a WMO region's per-GROUP
|
|
/// bounds, or a prop region's per-referrer-SET bounds (B4) — what the cull's admission
|
|
/// walk tests.
|
|
pub groups: Vec<(u16, Aabb)>,
|
|
/// A prop region's distinct referrer sets (B4), indexed by the same u16 as `groups` /
|
|
/// item selection: the rooms the PVS admission ORs over (empty set = unnamed — admitted
|
|
/// bare, never exterior-gated). Empty on cells and WMO regions.
|
|
pub sets: Vec<std::sync::Arc<[u16]>>,
|
|
}
|
|
|
|
/// Marks the ONE view the retained pass draws into — the world camera. Without this the node
|
|
/// would run for EVERY Core3d view, including the portrait-booth bakes, and paint world cells
|
|
/// into a portrait with the booth's view matrices (the cull list is the world camera's).
|
|
#[derive(Component, Clone, Copy, Default, ExtractComponent)]
|
|
pub(crate) struct StaticGxView;
|
|
|
|
/// Insert the marker on the world camera (idempotent — the camera can respawn).
|
|
fn mark_world_camera(
|
|
mut commands: Commands,
|
|
cam: Query<Entity, (With<crate::view::WorldCamera>, Without<StaticGxView>)>,
|
|
) {
|
|
for e in &cam {
|
|
commands.entity(e).insert(StaticGxView);
|
|
}
|
|
}
|
|
|
|
/// One admitted entry of the doodad-phase draw list (B4): ADT-doodad cells and WMO-prop
|
|
/// regions are the SAME drain phase in the 1.12 order (both are the M2 scene, after the WMO
|
|
/// phase), so the cull sorts them near-first TOGETHER — a far cell must not shade before a
|
|
/// near building's furniture.
|
|
#[derive(Clone, PartialEq)]
|
|
pub(crate) enum GxDoodadVis {
|
|
Cell((i32, i32)),
|
|
/// A prop region + this frame's per-referrer-SET verdicts.
|
|
Prop(Entity, GxSel),
|
|
}
|
|
|
|
/// One region's per-selection-grain verdicts for this frame — a WMO region's grain is the GROUP,
|
|
/// a prop region's the referrer-SET index, and both index these vectors the same way.
|
|
#[derive(Clone, Default, PartialEq)]
|
|
pub(crate) struct GxSel {
|
|
/// Drawn this frame: PVS ∧ frustum ∧ farclip ∧ the exterior window gate.
|
|
pub drawn: Vec<bool>,
|
|
/// On the interior fog lane — the client's per-group `[0xca7f00]`, resolved by the portal
|
|
/// flood ([`crate::wmo_portal::GroupPvs::interior_fog`]). Rides beside `drawn` because it is
|
|
/// the same walk's answer at the same grain, and because the node syncs it into the record
|
|
/// table exactly where it already syncs the kill column.
|
|
pub fog: Vec<bool>,
|
|
}
|
|
|
|
/// The published half the render world clones each frame. The baked regions sit behind `Arc`
|
|
/// (decision 1436): the 1435 band map priced the publish + extract clone pair at 0.39 ms/f —
|
|
/// tens of thousands of `GxItemDraw`s memcpy'd twice a frame — so the per-frame clones are
|
|
/// refcount bumps now, and the ONE writer that mutates a published region (the kill scan's
|
|
/// bitmap rebuild) pays a copy-on-write of that region alone, only on a real fade transition.
|
|
#[derive(Clone, Default, Resource, ExtractResource)]
|
|
pub(crate) struct GxWorld {
|
|
pub cells: HashMap<(i32, i32), std::sync::Arc<GxCellDraw>>,
|
|
/// This frame's doodad-phase draw list, near-first across cells AND prop regions (B4):
|
|
/// frustum + farclip + exterior gate at cell/set granularity, PVS per set.
|
|
pub visible: Vec<GxDoodadVis>,
|
|
/// The WMO regions (slice 2), keyed by placement instance entity.
|
|
pub wmos: HashMap<Entity, std::sync::Arc<GxCellDraw>>,
|
|
/// The prop regions (B4), keyed by the same instance entity as `wmos` (their lifecycle),
|
|
/// held apart so prop arrivals never re-bake building geometry.
|
|
pub props: HashMap<Entity, std::sync::Arc<GxCellDraw>>,
|
|
/// This frame's per-group admission per region (indexed by absolute group index): the
|
|
/// portal flood's verdict collapsed to CPU range selection — the node draws exactly the
|
|
/// runs whose group bit is set.
|
|
pub visible_wmos: Vec<(Entity, GxSel)>,
|
|
}
|
|
|
|
use super::pool::GxTexturePool;
|
|
|
|
/// Record column `w`, bit 14 — the per-item **interior fog** lane (decision 1787), written per
|
|
/// frame by the fog sync and read by `static_gx.wgsl`'s fog select. Bit 0 is the exile kill bit
|
|
/// and bits 1..=13 the interior-prop probe slot, so 14 is the first free bit.
|
|
const RECORD_FOG_BIT: u32 = 1 << 14;
|
|
|
|
/// MONKEY (room gate): record column `w` bits 15..=26 — the item's ROOM KEY, `group + 1` so that
|
|
/// **0 means "this item names no room"** and the shader's gate lets every fixture through (a
|
|
/// terrain-cell item; a WMO prop whose referrer set is not exactly one group). Bit 14 is the fog
|
|
/// lane, so 15 is the first free bit, and 12 bits covers every shipped WMO (the largest,
|
|
/// Stratholme, has 92 groups). **Keep in sync with `static_gx.wgsl`'s `RECORD_ROOM_SHIFT` /
|
|
/// `RECORD_ROOM_MASK`.**
|
|
const RECORD_ROOM_SHIFT: u32 = 15;
|
|
const RECORD_ROOM_MASK: u32 = 0xfff;
|
|
|
|
/// MONKEY (ext-class night law): record column `w`, bit 27 — this batch's group is EXTERIOR-class
|
|
/// but at BUILDING scale (an inn's shell, a basement stairwell), so after dark `static_gx.wgsl`
|
|
/// blends it off the sky-lit exterior law and onto the interior room law. The room key occupies
|
|
/// bits 15..=26, so 27 is the first free bit. **Keep in sync with `static_gx.wgsl`'s
|
|
/// `RECORD_EXT_NIGHT`.**
|
|
const RECORD_EXT_NIGHT_BIT: u32 = 1 << 27;
|
|
|
|
/// MONKEY (enclosed day floor): record column `w`, bit 28 — this batch's group is an INTERIOR room
|
|
/// inside a building-scale exterior shell, so by day `static_gx.wgsl` gives its room law a
|
|
/// sun-driven ambient floor (the daylight a doorway lets in, for the doorways this renderer cannot
|
|
/// locate). Bit 27 is the night law, so 28 is the first free bit; bits 29..=31 remain free.
|
|
/// **Keep in sync with `static_gx.wgsl`'s `RECORD_ENCLOSED`.**
|
|
const RECORD_ENCLOSED_BIT: u32 = 1 << 28;
|
|
|
|
/// The room key for one baked item: `group + 1` for a WMO surface batch, and for a PROP batch whose
|
|
/// referrer set names exactly one group (a prop in one room — the common case); 0 for a terrain
|
|
/// cell, and for a multi-room prop, which stays ungated because a single key cannot express it.
|
|
fn room_key(item: &GxItemDraw, sets: &[std::sync::Arc<[u16]>]) -> u32 {
|
|
let Some(sel) = item.group else {
|
|
return 0; // a cell item: no building, no room
|
|
};
|
|
let group = if sets.is_empty() {
|
|
sel // a WMO region's selection grain IS the group
|
|
} else {
|
|
match sets.get(usize::from(sel)).map(|s| &s[..]) {
|
|
Some([g]) => *g,
|
|
_ => return 0, // unnamed or multi-room prop
|
|
}
|
|
};
|
|
(u32::from(group) + 1).min(RECORD_ROOM_MASK) << RECORD_ROOM_SHIFT
|
|
}
|
|
|
|
/// One coalesced draw run: adjacent live bake items sharing (bind-group slot, pipeline
|
|
/// bucket, group). Killed items are SKIPPED at build time (B3): a run never carries an exiled
|
|
/// or gone item, so a far cell of fully-faded faders submits no vertex work at all (the WGSL
|
|
/// kill-bit collapse stays as the belt for the same frame's record table).
|
|
struct GxRun {
|
|
/// Index into the cell's `bind_groups` (NOT a pool class index).
|
|
slot: usize,
|
|
cutout: bool,
|
|
two_sided: bool,
|
|
index_range: Range<u32>,
|
|
/// The WMO group every item in this run belongs to (`None` = a cell run, always drawn) —
|
|
/// the bake sorts group inside (bucket, texture), so runs are group-homogeneous by
|
|
/// construction and the flood's per-group verdict selects whole runs.
|
|
group: Option<u16>,
|
|
}
|
|
|
|
/// A cell's assembled GPU state, cached across frames; rebuilt when the bake (mesh handle)
|
|
/// changes — which, with the shared pool, costs a record table and a few bind groups, never
|
|
/// a texture.
|
|
struct GxCellGpu {
|
|
mesh: AssetId<Mesh>,
|
|
/// One bind group per pool class this cell's items touch: (pool class index, group).
|
|
bind_groups: Vec<(u16, BindGroup)>,
|
|
record_table: Buffer,
|
|
#[allow(dead_code)] // held alive for the bind groups that reference it
|
|
cell_uniform: Buffer,
|
|
/// Per item: its index into `bind_groups` — the run key, kept for kill-driven run
|
|
/// rebuilds.
|
|
item_slot: Vec<u16>,
|
|
runs: Vec<GxRun>,
|
|
/// CPU copy of the record table — the kill-bit sync rewrites column 3 and re-uploads.
|
|
records: Vec<[u32; 4]>,
|
|
/// The `killed_rev` this table last uploaded.
|
|
killed_applied: u32,
|
|
/// The per-grain interior-fog verdict this table's records were last written from
|
|
/// ([`GxSel::fog`]) — empty until the first sync. Compared rather than revisioned: it is
|
|
/// tens of bools, it changes only when the camera changes rooms, and unlike the kill bitmap
|
|
/// it is produced by the per-frame cull rather than owned by the region.
|
|
fog_applied: Vec<bool>,
|
|
}
|
|
|
|
#[derive(Resource, Default)]
|
|
struct GxGpuCache {
|
|
cells: HashMap<(i32, i32), GxCellGpu>,
|
|
wmos: HashMap<Entity, GxCellGpu>,
|
|
props: HashMap<Entity, GxCellGpu>,
|
|
}
|
|
|
|
#[derive(Resource)]
|
|
struct GxPipelines {
|
|
view_layout: BindGroupLayoutDescriptor,
|
|
cell_layout: BindGroupLayoutDescriptor,
|
|
light_layout: BindGroupLayoutDescriptor,
|
|
/// MONKEY (torch shadows Phase 1): group 3 — the torch depth array + comparison sampler + the
|
|
/// ≤16-entry `TorchTable` uniform. Present on every static_gx pipeline (`TORCH_SHADOWS` def).
|
|
torch_layout: BindGroupLayoutDescriptor,
|
|
sampler: Sampler,
|
|
sampler_clamp: Sampler,
|
|
/// Keyed `(cutout, two_sided)`; specialized for the world view's (samples, format) pair —
|
|
/// re-specialized if that pair ever changes (a window move across displays).
|
|
pipelines: HashMap<(bool, bool), CachedRenderPipelineId>,
|
|
/// MONKEY (sun shadow perf): the key now carries the live `shadowFilter` too — the PCF branch
|
|
/// is a shader DEF, so a change has to re-specialize the family. Once per CHANGE, not per
|
|
/// frame: exactly the posture the (samples, format) pair beside it already has, and the same
|
|
/// caveat (a flip re-queues four pipelines rather than reviving the previous four — a cvar
|
|
/// A/B costs a shader build, a rendered frame costs nothing).
|
|
specialized_for: Option<(u32, TextureFormat, bool)>,
|
|
}
|
|
|
|
fn init_pipelines(
|
|
mut commands: Commands,
|
|
mesh_pipeline: Res<MeshPipeline>,
|
|
render_device: Res<RenderDevice>,
|
|
) {
|
|
// Use Bevy's real mesh-view layout for the retained pass. Besides the view and light records,
|
|
// this carries the directional shadow textures and comparison samplers produced for the
|
|
// world camera. The old two-binding layout could render the city, but it had no legal way for
|
|
// `static_gx.wgsl` to receive the shadow map.
|
|
let view_layout = mesh_pipeline
|
|
.get_view_layout(MeshPipelineViewLayoutKey::empty())
|
|
.main_layout
|
|
.clone();
|
|
// The pass's own extra (group 2): the shared light storage.
|
|
// MONKEY (room gate): binding 1 is the per-fixture ROOM CLAIM table — which WMO groups each
|
|
// packed interior fixture may light. Its own buffer, never a `WowLight` extension: that struct
|
|
// is mirrored by three shaders plus the portrait booth and a resize there vanishes every
|
|
// building in the world.
|
|
let light_layout = BindGroupLayoutDescriptor::new(
|
|
"static_gx_light_layout",
|
|
&BindGroupLayoutEntries::sequential(
|
|
ShaderStages::VERTEX_FRAGMENT,
|
|
(
|
|
storage_buffer_read_only_sized(false, None),
|
|
storage_buffer_read_only_sized(false, None),
|
|
),
|
|
),
|
|
);
|
|
let cell_layout = BindGroupLayoutDescriptor::new(
|
|
"static_gx_cell_layout",
|
|
&BindGroupLayoutEntries::sequential(
|
|
ShaderStages::VERTEX_FRAGMENT,
|
|
(
|
|
// origin (xyz) + pad
|
|
uniform_buffer_sized(false, Some(std::num::NonZero::new(16).unwrap())),
|
|
// item → texture-array layer
|
|
storage_buffer_read_only_sized(false, None),
|
|
texture_2d_array(TextureSampleType::Float { filterable: true }),
|
|
sampler(SamplerBindingType::Filtering),
|
|
sampler(SamplerBindingType::Filtering),
|
|
),
|
|
),
|
|
);
|
|
// TWO samplers per array — repeat and clamp, both matching the BLP loader's model albedo
|
|
// sampler exactly. Parity with `blp.rs` was always the rule here; what changed is that
|
|
// `blp.rs` no longer picks the numbers. The mip filter and the anisotropy are the process
|
|
// filter policy's (`benilla_assets::tex_filter`), which the reference forces onto every
|
|
// texture it creates from `[0x835250]`/`[0x835254]` — so this lane stays in step by asking
|
|
// the same question rather than by copying the same literals. The shader selects by the
|
|
// vertex word's wrap bits: a shared array cannot carry per-layer address modes. The rare
|
|
// MIXED-wrap batch (repeat one axis, clamp the other) keeps the repeat sampler plus the
|
|
// shader's half-texel inset clamp on its clamped axis — an approximation confined to that
|
|
// class (decision 0763's silhouette concern, honoured per axis).
|
|
// MONKEY (torch shadows Phase 1): group 3 — the interior torch depth map (a Depth32Float
|
|
// 2D-array sampled through a `GreaterEqual` comparison sampler) + the ≤16-entry `TorchTable`
|
|
// uniform (count/positions/view_projs). Fragment-only; declared behind the shader's
|
|
// `TORCH_SHADOWS` def, which the specialization below always sets for this pipeline family.
|
|
let torch_layout = BindGroupLayoutDescriptor::new(
|
|
"static_gx_torch_layout",
|
|
&BindGroupLayoutEntries::sequential(
|
|
ShaderStages::FRAGMENT,
|
|
(
|
|
// A depth 2D-array (= WGSL `texture_depth_2d_array`): no dedicated helper, so a
|
|
// `texture_2d_array` with the Depth sample type.
|
|
texture_2d_array(TextureSampleType::Depth),
|
|
sampler(SamplerBindingType::Comparison),
|
|
// MONKEY (static torch cache): count@0 (16), positions[16]@16 (256),
|
|
// view_projs[96]@272 (6144) = 6416 bytes. Both shader copies and the shared
|
|
// material storage buffer use these same bytes; a drift hides every building.
|
|
uniform_buffer_sized(
|
|
false,
|
|
Some(
|
|
std::num::NonZero::new(super::torch_depth::TORCH_TABLE_BYTES)
|
|
.expect("TORCH_TABLE_BYTES is non-zero"),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
let filter = benilla_assets::tex_filter();
|
|
let make = |label: &'static str, mode: AddressMode| {
|
|
render_device.create_sampler(&SamplerDescriptor {
|
|
label: Some(label),
|
|
min_filter: FilterMode::Linear,
|
|
mag_filter: FilterMode::Linear,
|
|
mipmap_filter: filter.gpu_mipmap_filter(),
|
|
address_mode_u: mode,
|
|
address_mode_v: mode,
|
|
anisotropy_clamp: filter.anisotropy_clamp(),
|
|
..Default::default()
|
|
})
|
|
};
|
|
// MONKEY (room gate): the claim table's persistent GPU buffer, written every frame by
|
|
// `prepare_room_claims`. Created here (RenderStartup) rather than beside the shared light
|
|
// buffer so the whole binding — layout, buffer, upload — lives with its one reader.
|
|
commands.insert_resource(GxRoomClaims(render_device.create_buffer(&BufferDescriptor {
|
|
label: Some("static_gx_room_claims"),
|
|
size: crate::lighting::room_claim_bytes(),
|
|
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
|
mapped_at_creation: false,
|
|
})));
|
|
commands.insert_resource(GxPipelines {
|
|
view_layout,
|
|
cell_layout,
|
|
light_layout,
|
|
torch_layout,
|
|
sampler: make("static_gx_repeat", AddressMode::Repeat),
|
|
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
|
|
pipelines: HashMap::default(),
|
|
specialized_for: None,
|
|
});
|
|
}
|
|
|
|
/// The pipeline-key query: the world view's (samples, format) inputs (the marker keeps booth
|
|
/// views out of it).
|
|
type GxViewKey = (
|
|
&'static ExtractedView,
|
|
&'static Msaa,
|
|
&'static ViewTarget,
|
|
&'static StaticGxView,
|
|
);
|
|
|
|
/// The fixed interleaved vertex layout the bake authors — **attribute-ID order**, which is
|
|
/// how Bevy interleaves a mesh's buffer: position (0), normal (1), uv (2), COLOR (5 — MOCV /
|
|
/// the baked constant tint, white default), then the custom word + anchor (988_101/988_102).
|
|
/// Kept in sync with `bake_cell` and `static_gx.wgsl`.
|
|
fn vertex_layout() -> VertexBufferLayout {
|
|
VertexBufferLayout {
|
|
array_stride: 64,
|
|
step_mode: VertexStepMode::Vertex,
|
|
attributes: vec![
|
|
VertexAttribute {
|
|
format: VertexFormat::Float32x3,
|
|
offset: 0,
|
|
shader_location: 0,
|
|
},
|
|
VertexAttribute {
|
|
format: VertexFormat::Float32x3,
|
|
offset: 12,
|
|
shader_location: 1,
|
|
},
|
|
VertexAttribute {
|
|
format: VertexFormat::Float32x2,
|
|
offset: 24,
|
|
shader_location: 2,
|
|
},
|
|
VertexAttribute {
|
|
format: VertexFormat::Float32x4,
|
|
offset: 32,
|
|
shader_location: 5,
|
|
},
|
|
VertexAttribute {
|
|
format: VertexFormat::Uint32,
|
|
offset: 48,
|
|
shader_location: 3,
|
|
},
|
|
VertexAttribute {
|
|
format: VertexFormat::Float32x3,
|
|
offset: 52,
|
|
shader_location: 4,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/// (Re-)specialize the four pipelines for the world view's (samples, format), and assemble
|
|
/// visible cells' GPU state: classes, arrays, layer table, bind groups, runs.
|
|
fn prepare_static_gx(
|
|
gx: Res<GxWorld>,
|
|
mut cache: ResMut<GxGpuCache>,
|
|
mut pool: ResMut<GxTexturePool>,
|
|
mut pipes: ResMut<GxPipelines>,
|
|
pipeline_cache: Res<PipelineCache>,
|
|
render_device: Res<RenderDevice>,
|
|
render_queue: Res<RenderQueue>,
|
|
asset_server: Res<AssetServer>,
|
|
mesh_pipeline: Res<MeshPipeline>,
|
|
images: Res<RenderAssets<GpuImage>>,
|
|
views: Query<GxViewKey>,
|
|
// MONKEY (sun shadow perf): the live `shadowFilter`, extracted from the main world. `Option`
|
|
// because `ExtractResourcePlugin` only publishes it while the lighting plugin is in the app
|
|
// (a bare static_gx harness has no lighting) — absent reads as the shipped Gaussian.
|
|
shadow_filter: Option<Res<crate::lighting::ShadowFilterGaussian>>,
|
|
) {
|
|
let shadow_gaussian = shadow_filter.map_or(true, |f| f.0);
|
|
let _t = super::gx_perf_guard(3);
|
|
// The world view's pipeline key (the marker keeps booth views out of it).
|
|
let Some((view, msaa, _, _)) = views.iter().next() else {
|
|
return;
|
|
};
|
|
let format = if view.hdr {
|
|
ViewTarget::TEXTURE_FORMAT_HDR
|
|
} else {
|
|
TextureFormat::bevy_default()
|
|
};
|
|
let key = (msaa.samples(), format, shadow_gaussian);
|
|
if pipes.specialized_for != Some(key) {
|
|
pipes.view_layout = mesh_pipeline
|
|
.get_view_layout(MeshPipelineViewLayoutKey::from(*msaa))
|
|
.main_layout
|
|
.clone();
|
|
let shader: Handle<Shader> =
|
|
asset_server.load("embedded://benilla_world/shaders/static_gx.wgsl");
|
|
pipes.pipelines.clear();
|
|
for cutout in [false, true] {
|
|
for two_sided in [false, true] {
|
|
let mut defs = vec![];
|
|
// The retained pipeline is custom, so it must opt into the same PCF branch the
|
|
// world camera's `ShadowFilteringMethod` gives every Bevy-material receiver;
|
|
// otherwise `shadows::fetch_directional_shadow` falls back to a constant 1.0.
|
|
// MONKEY (sun shadow perf): that method is now the live `shadowFilter` cvar, so the
|
|
// def follows it — the ground and the buildings standing on it must filter alike.
|
|
defs.push(ShaderDefVal::from(if shadow_gaussian {
|
|
"SHADOW_FILTER_METHOD_GAUSSIAN"
|
|
} else {
|
|
"SHADOW_FILTER_METHOD_HARDWARE_2X2"
|
|
}));
|
|
// MONKEY (torch shadows Phase 1): arm the shader's group-3 torch-map code. Set on
|
|
// EVERY static_gx pipeline so the group-3 bindings (always bound by the node) match.
|
|
defs.push(ShaderDefVal::from("TORCH_SHADOWS"));
|
|
if cutout {
|
|
defs.push(ShaderDefVal::from("GX_CUTOUT"));
|
|
}
|
|
let id = pipeline_cache.queue_render_pipeline(RenderPipelineDescriptor {
|
|
label: Some(
|
|
format!("static_gx c{} t{}", u8::from(cutout), u8::from(two_sided)).into(),
|
|
),
|
|
layout: vec![
|
|
pipes.view_layout.clone(),
|
|
pipes.cell_layout.clone(),
|
|
pipes.light_layout.clone(),
|
|
pipes.torch_layout.clone(),
|
|
],
|
|
vertex: VertexState {
|
|
shader: shader.clone(),
|
|
shader_defs: defs.clone(),
|
|
entry_point: Some("vertex".into()),
|
|
buffers: vec![vertex_layout()],
|
|
},
|
|
fragment: Some(FragmentState {
|
|
shader: shader.clone(),
|
|
shader_defs: defs,
|
|
entry_point: Some("fragment".into()),
|
|
targets: vec![Some(ColorTargetState {
|
|
format,
|
|
blend: None,
|
|
write_mask: ColorWrites::ALL,
|
|
})],
|
|
}),
|
|
primitive: PrimitiveState {
|
|
cull_mode: (!two_sided).then_some(Face::Back),
|
|
..Default::default()
|
|
},
|
|
depth_stencil: Some(DepthStencilState {
|
|
format: CORE_3D_DEPTH_FORMAT,
|
|
depth_write_enabled: true,
|
|
depth_compare: CompareFunction::GreaterEqual,
|
|
stencil: StencilState::default(),
|
|
bias: DepthBiasState::default(),
|
|
}),
|
|
multisample: MultisampleState {
|
|
count: msaa.samples(),
|
|
..Default::default()
|
|
},
|
|
..default()
|
|
});
|
|
pipes.pipelines.insert((cutout, two_sided), id);
|
|
}
|
|
}
|
|
pipes.specialized_for = Some(key);
|
|
}
|
|
|
|
// The map cleared (the main world published an empty set): the pool's assignments point
|
|
// at content the world no longer holds — reset it with the cache. Never fires on a mere
|
|
// area change; only `StaticGx::clear` empties ALL published maps.
|
|
if gx.cells.is_empty() && gx.wmos.is_empty() && gx.props.is_empty() {
|
|
if !pool.is_empty() {
|
|
*pool = GxTexturePool::default();
|
|
}
|
|
cache.cells.clear();
|
|
cache.wmos.clear();
|
|
cache.props.clear();
|
|
return;
|
|
}
|
|
|
|
// Drop cache entries whose region vanished or re-baked.
|
|
cache
|
|
.cells
|
|
.retain(|c, gpu| gx.cells.get(c).is_some_and(|d| d.mesh.id() == gpu.mesh));
|
|
cache
|
|
.wmos
|
|
.retain(|e, gpu| gx.wmos.get(e).is_some_and(|d| d.mesh.id() == gpu.mesh));
|
|
cache
|
|
.props
|
|
.retain(|e, gpu| gx.props.get(e).is_some_and(|d| d.mesh.id() == gpu.mesh));
|
|
|
|
for vis in &gx.visible {
|
|
match vis {
|
|
GxDoodadVis::Cell(cell) => {
|
|
if cache.cells.contains_key(cell) {
|
|
continue;
|
|
}
|
|
let Some(draw) = gx.cells.get(cell) else {
|
|
continue;
|
|
};
|
|
if let Some(gpu) = assemble_region(
|
|
draw,
|
|
0, // a terrain cell belongs to no building (MONKEY, room gate)
|
|
&mut pool,
|
|
&pipes,
|
|
&pipeline_cache,
|
|
&render_device,
|
|
&render_queue,
|
|
&images,
|
|
) {
|
|
cache.cells.insert(*cell, gpu);
|
|
}
|
|
}
|
|
GxDoodadVis::Prop(entity, _) => {
|
|
if cache.props.contains_key(entity) {
|
|
continue;
|
|
}
|
|
let Some(draw) = gx.props.get(entity) else {
|
|
continue;
|
|
};
|
|
if let Some(gpu) = assemble_region(
|
|
draw,
|
|
entity.index().index(),
|
|
&mut pool,
|
|
&pipes,
|
|
&pipeline_cache,
|
|
&render_device,
|
|
&render_queue,
|
|
&images,
|
|
) {
|
|
cache.props.insert(*entity, gpu);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (entity, _) in &gx.visible_wmos {
|
|
if cache.wmos.contains_key(entity) {
|
|
continue;
|
|
}
|
|
let Some(draw) = gx.wmos.get(entity) else {
|
|
continue;
|
|
};
|
|
if let Some(gpu) = assemble_region(
|
|
draw,
|
|
entity.index().index(),
|
|
&mut pool,
|
|
&pipes,
|
|
&pipeline_cache,
|
|
&render_device,
|
|
&render_queue,
|
|
&images,
|
|
) {
|
|
cache.wmos.insert(*entity, gpu);
|
|
}
|
|
}
|
|
// Encode this frame's queued layer copies, exactly once (B3 — see the module doc; B2's
|
|
// per-cell pending list was never drained and re-encoded every frame).
|
|
pool.drain_pending(&render_device, &render_queue);
|
|
|
|
// The exile kill-bit sync (B2, 1431): when the scan's bitmap revision moved, rewrite the
|
|
// record table's kill column, re-upload, and REBUILD THE RUNS (B3) so killed items stop
|
|
// being submitted at all. One whole-table write + one CPU coalesce per changed cell per
|
|
// change frame — band crossings are rare and a table is tens of KB; a cell that changed
|
|
// while out of view syncs on re-entry (the revision mismatch persists until applied).
|
|
for vis in &gx.visible {
|
|
let GxDoodadVis::Cell(cell) = vis else {
|
|
continue; // prop regions carry no faders — their bitmap never revs
|
|
};
|
|
let (Some(gpu), Some(draw)) = (cache.cells.get_mut(cell), gx.cells.get(cell)) else {
|
|
continue;
|
|
};
|
|
if gpu.killed_applied == draw.killed_rev {
|
|
continue;
|
|
}
|
|
for (i, rec) in gpu.records.iter_mut().enumerate() {
|
|
// Column w carries the probe slot (B4) and the room key (MONKEY, room gate) in the
|
|
// high bits — rewrite only bit 0.
|
|
rec[3] = (rec[3] & !1) | kill_bit(&draw.killed, i);
|
|
}
|
|
render_queue.write_buffer(&gpu.record_table, 0, bytemuck::cast_slice(&gpu.records));
|
|
gpu.runs = build_runs(&draw.draws, &gpu.item_slot, &draw.killed);
|
|
gpu.killed_applied = draw.killed_rev;
|
|
}
|
|
|
|
// The interior-fog sync (decision 1787): the client's per-group `[0xca7f00]` decides which
|
|
// fog triple a WMO group's surfaces — and its doodad props — are pushed with, so it is a
|
|
// per-frame property of the SELECTION, not of the bake. It rides the record table's w column
|
|
// (bit `RECORD_FOG_BIT`) beside the kill bit, written per item from its own selection grain.
|
|
// Rewritten only when the verdict actually moves — which is when the camera changes rooms —
|
|
// and it never touches run membership, so no coalesce is owed.
|
|
let sync_fog = |gpu: &mut GxCellGpu, draw: &GxCellDraw, sel: &GxSel| {
|
|
if gpu.fog_applied == sel.fog {
|
|
return;
|
|
}
|
|
for (rec, item) in gpu.records.iter_mut().zip(&draw.draws) {
|
|
let on = item
|
|
.group
|
|
.is_some_and(|g| sel.fog.get(usize::from(g)).copied().unwrap_or(false));
|
|
rec[3] = (rec[3] & !RECORD_FOG_BIT) | (u32::from(on) * RECORD_FOG_BIT);
|
|
}
|
|
render_queue.write_buffer(&gpu.record_table, 0, bytemuck::cast_slice(&gpu.records));
|
|
gpu.fog_applied.clone_from(&sel.fog);
|
|
};
|
|
for (entity, sel) in &gx.visible_wmos {
|
|
if let (Some(gpu), Some(draw)) = (cache.wmos.get_mut(entity), gx.wmos.get(entity)) {
|
|
sync_fog(gpu, draw, sel);
|
|
}
|
|
}
|
|
for vis in &gx.visible {
|
|
let GxDoodadVis::Prop(entity, sel) = vis else {
|
|
continue; // cell items are never on the interior lane
|
|
};
|
|
if let (Some(gpu), Some(draw)) = (cache.props.get_mut(entity), gx.props.get(entity)) {
|
|
sync_fog(gpu, draw, sel);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Assemble one region's GPU state against the shared pool: pool slots for its textures, the
|
|
/// per-item record table, one bind group per touched pool class, coalesced runs. `None` while
|
|
/// any member texture is not yet resident — the region simply isn't drawn that frame (the
|
|
/// entity path streams batches in piecewise; this is the same arrival class; slots already
|
|
/// assigned stay assigned, so the retry finishes cheaper).
|
|
fn assemble_region(
|
|
draw: &GxCellDraw,
|
|
// MONKEY (room gate): this region's WMO placement instance (entity index), or 0 for a terrain
|
|
// cell — half of the surface room key the interior lane gates on.
|
|
room_instance: u32,
|
|
pool: &mut GxTexturePool,
|
|
pipes: &GxPipelines,
|
|
pipeline_cache: &PipelineCache,
|
|
render_device: &RenderDevice,
|
|
render_queue: &RenderQueue,
|
|
images: &RenderAssets<GpuImage>,
|
|
) -> Option<GxCellGpu> {
|
|
// Resolve every item to a pool (class, layer) — deduped globally by texture id (many
|
|
// items share one texture: Stormwind's region carries 3,042 items over a few hundred
|
|
// distinct BLPs, and neighbouring cells repeat most of them; per-item layers blew the
|
|
// D2-array limit the moment a city root baked, and per-CELL arrays paid the driver churn
|
|
// 1431 measured). Untextured items ride the white class (never sampled — TEXTURED clear).
|
|
// MONKEY (room gate): whether this region can carry a room key at all (see the cell uniform
|
|
// below for the 2^24 bound and why both halves of the key must fail open together).
|
|
let gated = room_instance > 0 && room_instance < (1 << 24);
|
|
let mut white: Option<u16> = None;
|
|
let mut item_class_layer: Vec<(u16, u16)> = Vec::with_capacity(draw.draws.len());
|
|
for item in &draw.draws {
|
|
item_class_layer.push(match item.texture {
|
|
Some(tex) => pool.assign(tex, images.get(tex)?, render_device),
|
|
None => {
|
|
let w = *white.get_or_insert_with(|| pool.white(render_device, render_queue));
|
|
(w, 0)
|
|
}
|
|
});
|
|
}
|
|
// The per-item record table: [layer, batch-order nudge, packed SIDN, kill bit + probe
|
|
// slot] per item — the vertex word's low bits index it. Column 3's bit 0 is the exile
|
|
// kill bit (B2), folded from the published bitmap here and kept in sync by
|
|
// `prepare_static_gx`'s revision check (hence COPY_DST); bits 1..14 carry the interior
|
|
// prop's SH-probe slot (B4 — 13 bits fits `MAX_PROP_PROBES` exactly).
|
|
let records: Vec<[u32; 4]> = draw
|
|
.draws
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, item)| {
|
|
[
|
|
u32::from(item_class_layer[i].1),
|
|
u32::from(item.order),
|
|
u32::from(item.sidn[0])
|
|
| (u32::from(item.sidn[1]) << 8)
|
|
| (u32::from(item.sidn[2]) << 16),
|
|
kill_bit(&draw.killed, i)
|
|
| (u32::from(item.slot) << 1)
|
|
| if gated { room_key(item, &draw.sets) } else { 0 }
|
|
// MONKEY (ext-class night law): unconditional on `gated` — the night law is a
|
|
// property of the GROUP's authored class and box, not of whether this region
|
|
// could carry a room key. An ungated region's ext-class group still must not
|
|
// read as night sky; its `interior_room_light` simply falls open, exactly as an
|
|
// ungated interior group's already does.
|
|
| if item.ext_night { RECORD_EXT_NIGHT_BIT } else { 0 }
|
|
// MONKEY (enclosed day floor): unconditional on `gated` for the same reason —
|
|
// "is this group a room in a building" is a fact about the authored group
|
|
// table, not about whether this region could carry a room key.
|
|
| if item.enclosed { RECORD_ENCLOSED_BIT } else { 0 },
|
|
]
|
|
})
|
|
.collect();
|
|
let record_table = render_device.create_buffer_with_data(&BufferInitDescriptor {
|
|
label: Some("static_gx_records"),
|
|
contents: bytemuck::cast_slice(&records),
|
|
usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
|
|
});
|
|
// MONKEY (room gate): the uniform's fourth lane was a hard 0.0 pad and now carries this
|
|
// REGION's building identity — the WMO placement instance's entity index — so the declared
|
|
// 16-byte size (and every bind-group layout built against it) does not move. Per REGION rather
|
|
// than per item, because a region is exactly one placement; that is what keeps the per-item key
|
|
// down to the group alone.
|
|
//
|
|
// As a NUMBER, not as bits: an entity index is a small integer and `f32::from_bits(12345)` is a
|
|
// DENORMAL, which a driver flushing denormals to zero would collapse to 0 — every building
|
|
// sharing identity 0, and every gate then a coin toss. An f32 holds every integer below 2^24
|
|
// exactly, which no live entity index approaches, so `as f32` / `u32()` is lossless. At or above
|
|
// that bound (and for a terrain cell, index 0) the region is packed UNGATED on BOTH lanes —
|
|
// identity AND per-item key — because half a key would fail CLOSED and black the building out.
|
|
let room_instance = if gated { room_instance } else { 0 };
|
|
let cell_uniform = render_device.create_buffer_with_data(&BufferInitDescriptor {
|
|
label: Some("static_gx_cell"),
|
|
contents: bytemuck::cast_slice(&[
|
|
draw.origin.x,
|
|
draw.origin.y,
|
|
draw.origin.z,
|
|
room_instance as f32,
|
|
]),
|
|
usage: BufferUsages::UNIFORM,
|
|
});
|
|
// One bind group per DISTINCT pool class this region touches; items collapse to slots.
|
|
let cell_layout = pipeline_cache.get_bind_group_layout(&pipes.cell_layout);
|
|
let mut bind_groups: Vec<(u16, BindGroup)> = Vec::new();
|
|
let mut item_slot: Vec<u16> = Vec::with_capacity(draw.draws.len());
|
|
for &(class, _) in &item_class_layer {
|
|
let slot = match bind_groups.iter().position(|(c, _)| *c == class) {
|
|
Some(s) => s,
|
|
None => {
|
|
let bg = render_device.create_bind_group(
|
|
"static_gx_cell",
|
|
&cell_layout,
|
|
&BindGroupEntries::sequential((
|
|
cell_uniform.as_entire_binding(),
|
|
record_table.as_entire_binding(),
|
|
pool.view(class),
|
|
&pipes.sampler,
|
|
&pipes.sampler_clamp,
|
|
)),
|
|
);
|
|
bind_groups.push((class, bg));
|
|
bind_groups.len() - 1
|
|
}
|
|
};
|
|
item_slot.push(u16::try_from(slot).expect("gx region under u16 slots"));
|
|
}
|
|
let runs = build_runs(&draw.draws, &item_slot, &draw.killed);
|
|
Some(GxCellGpu {
|
|
mesh: draw.mesh.id(),
|
|
bind_groups,
|
|
record_table,
|
|
cell_uniform,
|
|
item_slot,
|
|
runs,
|
|
records,
|
|
killed_applied: draw.killed_rev,
|
|
// Empty ⇒ the first fog sync always fires (the baked records carry no lane bit).
|
|
fog_applied: Vec::new(),
|
|
})
|
|
}
|
|
|
|
/// Coalesce adjacent LIVE items sharing (slot, bucket, group) into draw runs (the bake sorted
|
|
/// by (bucket, texture[, group]), so repeated textures and same-bucket spans fuse; a WMO run
|
|
/// never crosses a group boundary — the selection grain). Killed items are skipped whole
|
|
/// (B3): their vertices are never submitted, and the kill-bit sync rebuilds the runs on every
|
|
/// bitmap revision — a fully-gone cell coalesces to NOTHING.
|
|
fn build_runs(draws: &[GxItemDraw], item_slot: &[u16], killed: &[u64]) -> Vec<GxRun> {
|
|
let mut runs: Vec<GxRun> = Vec::new();
|
|
for (i, item) in draws.iter().enumerate() {
|
|
if kill_bit(killed, i) != 0 {
|
|
continue;
|
|
}
|
|
let slot = usize::from(item_slot[i]);
|
|
match runs.last_mut() {
|
|
Some(r)
|
|
if r.slot == slot
|
|
&& r.cutout == item.cutout
|
|
&& r.two_sided == item.two_sided
|
|
&& r.group == item.group
|
|
&& r.index_range.end == item.index_range.start =>
|
|
{
|
|
r.index_range.end = item.index_range.end;
|
|
}
|
|
_ => runs.push(GxRun {
|
|
slot,
|
|
cutout: item.cutout,
|
|
two_sided: item.two_sided,
|
|
index_range: item.index_range.clone(),
|
|
group: item.group,
|
|
}),
|
|
}
|
|
}
|
|
runs
|
|
}
|
|
|
|
/// Record-table column 3: item `i`'s exile kill bit from the published bitmap.
|
|
fn kill_bit(killed: &[u64], i: usize) -> u32 {
|
|
u32::from(
|
|
killed
|
|
.get(i / 64)
|
|
.is_some_and(|w| w & (1u64 << (i % 64)) != 0),
|
|
)
|
|
}
|
|
|
|
/// The retained pass's extra bind group (group 2): the shared light buffer — the SAME
|
|
/// `wow_shared_light` storage every material binds (1429: identical lighting by construction) —
|
|
/// Group 0 is Bevy's standard mesh-view bind group, which supplies the view matrices,
|
|
/// directional-light records, and shadow textures.
|
|
#[derive(Resource)]
|
|
struct GxLightBind(BindGroup);
|
|
|
|
/// MONKEY (room gate): the persistent room-claim storage buffer (group 2, binding 1). Written whole
|
|
/// every frame from the extracted [`crate::lighting::RoomClaimTable`] — 32 KB since MONKEY (soft
|
|
/// portal claims) widened the record from 8 to 32 words (the per-claim fade), one `write_buffer`,
|
|
/// and the table is rebuilt from scratch by the packer anyway, so there is nothing cheaper to
|
|
/// diff against. Persistent so the per-region bind groups that reference it never need rebuilding.
|
|
#[derive(Resource)]
|
|
struct GxRoomClaims(Buffer);
|
|
|
|
fn prepare_room_claims(
|
|
queue: Res<RenderQueue>,
|
|
buffer: Option<Res<GxRoomClaims>>,
|
|
claims: Option<Res<crate::lighting::RoomClaimTable>>,
|
|
// MONKEY (torch lane perf): the words last actually uploaded, keyed by the buffer they went to.
|
|
mut last: Local<Option<(BufferId, Vec<u32>)>>,
|
|
) {
|
|
let (Some(buffer), Some(claims)) = (buffer, claims) else {
|
|
return;
|
|
};
|
|
// MONKEY (torch lane perf): skip the write when the table is bit-identical to what is already
|
|
// in the buffer. The packer does rebuild it from scratch every frame, but the RESULT is
|
|
// unchanged for as long as the player stands in one room with the same fixtures claiming it —
|
|
// which is most of the time indoors. A 32 KB `memcmp` costs a fraction of the 32 KB staging
|
|
// copy it saves, and the comparison is against what we WROTE, so it cannot disagree with the
|
|
// buffer's real contents.
|
|
let id = buffer.0.id();
|
|
if last.as_ref().is_some_and(|(b, w)| *b == id && w[..] == claims.0[..]) {
|
|
return;
|
|
}
|
|
queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&claims.0[..]));
|
|
// Reuse the cache's allocation where there is one — this runs on a frame where the claims
|
|
// genuinely changed, and a fresh 32 KB `Vec` per such frame would be churn for nothing.
|
|
if let Some((b, w)) = last.as_mut() {
|
|
if *b == id {
|
|
w.copy_from_slice(&claims.0[..]);
|
|
return;
|
|
}
|
|
}
|
|
*last = Some((id, claims.0.to_vec()));
|
|
}
|
|
|
|
fn prepare_view_bind(
|
|
mut commands: Commands,
|
|
pipes: Res<GxPipelines>,
|
|
pipeline_cache: Res<PipelineCache>,
|
|
render_device: Res<RenderDevice>,
|
|
light: Option<Res<crate::lighting::SharedLightBuffer>>,
|
|
claims: Option<Res<GxRoomClaims>>,
|
|
) {
|
|
let (Some(light), Some(claims)) = (light, claims) else {
|
|
return;
|
|
};
|
|
let layout = pipeline_cache.get_bind_group_layout(&pipes.light_layout);
|
|
commands.insert_resource(GxLightBind(render_device.create_bind_group(
|
|
"static_gx_light",
|
|
&layout,
|
|
&BindGroupEntries::sequential((
|
|
light.0.as_entire_binding(),
|
|
claims.0.as_entire_binding(),
|
|
)),
|
|
)));
|
|
}
|
|
|
|
/// The retained pass's group-3 bind group (MONKEY, torch shadows Phase 1): the interior torch depth
|
|
/// array (Phase 3A: the shared [`super::torch_depth::TorchDepthImage`]'s `GpuImage` D2Array view —
|
|
/// the same texture the entity receiver binds through its material) + static_gx's own comparison
|
|
/// sampler (from [`super::torch_depth::TorchDepthTargets`]) and the `TorchTable` uniform packed from
|
|
/// the extracted [`super::torch_depth::TorchShadowViews`] by the shared
|
|
/// [`super::torch_depth::TorchTableUniform::pack`]. ALWAYS built (with `count = 0` and a zero table
|
|
/// when the lane is off or absent) so the pipeline's group 3 is never left unbound — except while the
|
|
/// shared image is not yet resident, when static_gx skips the frame (the node early-outs on a
|
|
/// missing `GxTorchBind`).
|
|
/// MONKEY (torch lane perf): the bind group is CACHED with the exact table bytes and the depth
|
|
/// texture it was built over. Both used to be rebuilt from scratch every frame — a fresh 6416-byte
|
|
/// uniform buffer plus a fresh `BindGroup` object, for contents that are identical on every frame
|
|
/// the player stands still. Nothing else about the group can change while those two match: the
|
|
/// sampler and the layout are `RenderStartup` singletons, and the texture id covers the one case
|
|
/// where the shared image is ever re-prepared.
|
|
#[derive(Resource)]
|
|
struct GxTorchBind {
|
|
group: BindGroup,
|
|
table: super::torch_depth::TorchTableUniform,
|
|
texture: TextureId,
|
|
}
|
|
|
|
fn prepare_torch_bind(
|
|
mut commands: Commands,
|
|
pipes: Res<GxPipelines>,
|
|
pipeline_cache: Res<PipelineCache>,
|
|
render_device: Res<RenderDevice>,
|
|
targets: Option<Res<super::torch_depth::TorchDepthTargets>>,
|
|
views: Option<Res<super::torch_depth::TorchShadowViews>>,
|
|
image: Option<Res<super::torch_depth::TorchDepthImage>>,
|
|
images: Res<RenderAssets<GpuImage>>,
|
|
cached: Option<ResMut<GxTorchBind>>,
|
|
) {
|
|
// The sampler/pipeline are created in RenderStartup; the shared image is prepared by the
|
|
// render-asset pass (before this set). Without either, group 3 has nothing to bind this frame.
|
|
let Some(targets) = targets else {
|
|
return;
|
|
};
|
|
let Some(gpu_image) = image.as_ref().and_then(|h| images.get(&h.0)) else {
|
|
return;
|
|
};
|
|
let table = super::torch_depth::TorchTableUniform::pack(views.as_deref());
|
|
let texture = gpu_image.texture.id();
|
|
// The compare is on the BYTES, not on the source resource's change tick: `TorchShadowViews` is
|
|
// republished every frame by the app lane whether or not anything in it moved, so a change
|
|
// detector here would never fire negative.
|
|
if let Some(mut cached) = cached {
|
|
if cached.texture == texture
|
|
&& bytemuck::bytes_of(&cached.table) == bytemuck::bytes_of(&table)
|
|
{
|
|
return;
|
|
}
|
|
// Rebuild in place — one `Res` write instead of a deferred `insert_resource` per frame.
|
|
*cached = GxTorchBind {
|
|
group: build_torch_bind(&pipes, &pipeline_cache, &render_device, gpu_image,
|
|
targets.sampler(), &table),
|
|
table,
|
|
texture,
|
|
};
|
|
return;
|
|
}
|
|
commands.insert_resource(GxTorchBind {
|
|
group: build_torch_bind(&pipes, &pipeline_cache, &render_device, gpu_image,
|
|
targets.sampler(), &table),
|
|
table,
|
|
texture,
|
|
});
|
|
}
|
|
|
|
/// The group-3 bind group itself (see [`GxTorchBind`]) — the uniform buffer is created here because
|
|
/// its contents ARE the cache key, so a new buffer is only ever made on a frame the table moved.
|
|
fn build_torch_bind(
|
|
pipes: &GxPipelines,
|
|
pipeline_cache: &PipelineCache,
|
|
render_device: &RenderDevice,
|
|
gpu_image: &GpuImage,
|
|
sampler: &Sampler,
|
|
table: &super::torch_depth::TorchTableUniform,
|
|
) -> BindGroup {
|
|
let buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {
|
|
label: Some("static_gx_torch_table"),
|
|
contents: bytemuck::bytes_of(table),
|
|
usage: BufferUsages::UNIFORM,
|
|
});
|
|
let layout = pipeline_cache.get_bind_group_layout(&pipes.torch_layout);
|
|
render_device.create_bind_group(
|
|
"static_gx_torch",
|
|
&layout,
|
|
&BindGroupEntries::sequential((
|
|
// MONKEY (live bank rank): the image's default D2Array view spans all 144 layers:
|
|
// 96 STATIC faces addressed by matrices plus 48 LIVE copies addressed by the
|
|
// ascending set-bit rank in `count.z`. (wgpu's default view for a
|
|
// multi-layer 2D texture; `torch_depth.rs` spells out the guarantee).
|
|
&gpu_image.texture_view,
|
|
sampler,
|
|
buffer.as_entire_binding(),
|
|
)),
|
|
)
|
|
}
|
|
|
|
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
|
struct StaticGxLabel;
|
|
|
|
#[derive(Default)]
|
|
struct StaticGxNode;
|
|
|
|
impl ViewNode for StaticGxNode {
|
|
type ViewQuery = (
|
|
&'static ViewTarget,
|
|
&'static ViewDepthTexture,
|
|
&'static ViewUniformOffset,
|
|
&'static ViewLightsUniformOffset,
|
|
&'static ViewFogUniformOffset,
|
|
&'static ViewLightProbesUniformOffset,
|
|
&'static ViewScreenSpaceReflectionsUniformOffset,
|
|
&'static ViewEnvironmentMapUniformOffset,
|
|
Option<&'static OrderIndependentTransparencySettingsOffset>,
|
|
&'static MeshViewBindGroup,
|
|
// The world camera only — a booth bake must never receive world cells (see the marker).
|
|
&'static StaticGxView,
|
|
);
|
|
|
|
fn run<'w>(
|
|
&self,
|
|
_graph: &mut RenderGraphContext,
|
|
render_context: &mut RenderContext<'w>,
|
|
(
|
|
target,
|
|
depth,
|
|
view_offset,
|
|
view_lights,
|
|
view_fog,
|
|
view_light_probes,
|
|
view_ssr,
|
|
view_environment_map,
|
|
maybe_oit,
|
|
view_bind,
|
|
_marker,
|
|
): QueryItem<'w, '_, Self::ViewQuery>,
|
|
world: &'w World,
|
|
) -> Result<(), NodeRunError> {
|
|
let _t = super::gx_perf_guard(4);
|
|
let gx = world.resource::<GxWorld>();
|
|
if gx.visible.is_empty() && gx.visible_wmos.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let cache = world.resource::<GxGpuCache>();
|
|
let pipes = world.resource::<GxPipelines>();
|
|
let pipeline_cache = world.resource::<PipelineCache>();
|
|
let Some(light_bind) = world.get_resource::<GxLightBind>() else {
|
|
return Ok(());
|
|
};
|
|
// MONKEY (torch shadows Phase 1): group 3 is part of every static_gx pipeline now, so it
|
|
// must be bound before any draw. `prepare_torch_bind` always builds it (count 0 when the
|
|
// lane is off); a missing one means the depth targets weren't ready — skip the frame.
|
|
let Some(torch_bind) = world.get_resource::<GxTorchBind>() else {
|
|
return Ok(());
|
|
};
|
|
let meshes = world.resource::<RenderAssets<RenderMesh>>();
|
|
let allocator = world.resource::<MeshAllocator>();
|
|
// Cells draw whole; a WMO region draws only the runs whose group the flood admitted
|
|
// this frame, a prop region only the runs whose referrer SET the walk admitted (the
|
|
// selection rides beside the gpu state — `None` = draw everything). WMO regions
|
|
// FIRST, then the doodad phase — cells and prop regions in one near-first order
|
|
// (B3/B4): the real client's own drain order (1429's byte-true anchor: terrain →
|
|
// WMO → … → doodad), and the buildings are the frame's best early-z occluders for
|
|
// the doodads behind them.
|
|
let mut resolved: Vec<(&GxCellGpu, &GxCellDraw, Option<&GxSel>)> = Vec::new();
|
|
for (entity, sel) in &gx.visible_wmos {
|
|
if let (Some(gpu), Some(draw)) = (cache.wmos.get(entity), gx.wmos.get(entity)) {
|
|
resolved.push((gpu, draw, Some(sel)));
|
|
}
|
|
}
|
|
for vis in &gx.visible {
|
|
match vis {
|
|
GxDoodadVis::Cell(cell) => {
|
|
if let (Some(gpu), Some(draw)) = (cache.cells.get(cell), gx.cells.get(cell)) {
|
|
resolved.push((gpu, draw, None));
|
|
}
|
|
}
|
|
GxDoodadVis::Prop(entity, sel) => {
|
|
if let (Some(gpu), Some(draw)) = (cache.props.get(entity), gx.props.get(entity))
|
|
{
|
|
resolved.push((gpu, draw, Some(sel)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if resolved.is_empty() {
|
|
return Ok(());
|
|
}
|
|
// (Layer copies are encoded + submitted by `prepare_static_gx`'s pool drain, exactly
|
|
// once per texture — B3; the node encodes nothing outside its pass anymore.)
|
|
// The four bucket pipelines must all be compiled before the first draw (all-or-none:
|
|
// a cell drawing only its opaque half would flash cutout content off for a frame).
|
|
let mut ready: HashMap<(bool, bool), &RenderPipeline> = HashMap::default();
|
|
for (k, id) in &pipes.pipelines {
|
|
match pipeline_cache.get_render_pipeline(*id) {
|
|
Some(p) => {
|
|
ready.insert(*k, p);
|
|
}
|
|
None => return Ok(()),
|
|
}
|
|
}
|
|
let depth_attachment = depth.get_attachment(StoreOp::Store);
|
|
let color_attachment = target.get_color_attachment();
|
|
// The pass's diagnostic span — `render/static_gx/elapsed_gpu` where the device times
|
|
// passes (Vulkan/DX12), the CPU span everywhere: the journal's `gpu_static` column (2008).
|
|
let diagnostics = render_context.diagnostic_recorder();
|
|
let mut pass = render_context.begin_tracked_render_pass(RenderPassDescriptor {
|
|
label: Some("static_gx"),
|
|
color_attachments: &[Some(color_attachment)],
|
|
depth_stencil_attachment: Some(depth_attachment),
|
|
timestamp_writes: None,
|
|
occlusion_query_set: None,
|
|
});
|
|
let span = diagnostics.pass_span(&mut pass, "static_gx");
|
|
let mut view_offsets = vec![
|
|
view_offset.offset,
|
|
view_lights.offset,
|
|
view_fog.offset,
|
|
**view_light_probes,
|
|
**view_ssr,
|
|
**view_environment_map,
|
|
];
|
|
if let Some(oit) = maybe_oit {
|
|
view_offsets.push(oit.offset);
|
|
}
|
|
pass.set_bind_group(0, &view_bind.main, &view_offsets);
|
|
for (gpu, draw, sel) in &resolved {
|
|
let Some(mesh) = meshes.get(draw.mesh.id()) else {
|
|
continue;
|
|
};
|
|
let (Some(vslice), Some(islice)) = (
|
|
allocator.mesh_vertex_slice(&draw.mesh.id()),
|
|
allocator.mesh_index_slice(&draw.mesh.id()),
|
|
) else {
|
|
continue;
|
|
};
|
|
let index_format = match &mesh.buffer_info {
|
|
bevy::render::mesh::RenderMeshBufferInfo::Indexed { index_format, .. } => {
|
|
*index_format
|
|
}
|
|
bevy::render::mesh::RenderMeshBufferInfo::NonIndexed => continue,
|
|
};
|
|
pass.set_vertex_buffer(0, vslice.buffer.slice(..));
|
|
pass.set_index_buffer(islice.buffer.slice(..), index_format);
|
|
for run in &gpu.runs {
|
|
// The PVS range selection (1429's collapse): a WMO run draws iff its group's
|
|
// admission bit is set this frame; a cell run always draws.
|
|
if let (Some(sel), Some(group)) = (sel, run.group) {
|
|
if !sel.drawn.get(usize::from(group)).copied().unwrap_or(false) {
|
|
continue;
|
|
}
|
|
}
|
|
pass.set_render_pipeline(ready[&(run.cutout, run.two_sided)]);
|
|
pass.set_bind_group(1, &gpu.bind_groups[run.slot].1, &[]);
|
|
pass.set_bind_group(2, &light_bind.0, &[]);
|
|
pass.set_bind_group(3, &torch_bind.group, &[]);
|
|
pass.draw_indexed(
|
|
(islice.range.start + run.index_range.start)
|
|
..(islice.range.start + run.index_range.end),
|
|
i32::try_from(vslice.range.start).unwrap_or(0),
|
|
0..1,
|
|
);
|
|
}
|
|
}
|
|
span.end(&mut pass);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Wire the render half (called by the plugin only when armed).
|
|
pub(super) fn build(app: &mut App) {
|
|
// (The shader registers in `crate::shaders` with the other engine WGSL — `embedded_asset!`
|
|
// derives its path from the CALLING file, so registering here would mis-prefix it.)
|
|
// The main-world half lives inside `StaticGx`; `publish_gx_world` (registered by the
|
|
// plugin, chained after the scene walk) mirrors it into this standalone resource for
|
|
// `ExtractResourcePlugin` to clone.
|
|
app.add_plugins((
|
|
ExtractResourcePlugin::<GxWorld>::default(),
|
|
ExtractComponentPlugin::<StaticGxView>::default(),
|
|
));
|
|
app.init_resource::<GxWorld>();
|
|
app.add_systems(Update, mark_world_camera);
|
|
// MONKEY (torch shadows Phase 1): the depth-map targets + node (its own RenderStartup + graph
|
|
// wiring). Built before we borrow the render app below — the two borrows are sequential.
|
|
super::torch_depth::build(app);
|
|
let Some(render_app) = app.get_sub_app_mut(bevy::render::RenderApp) else {
|
|
return;
|
|
};
|
|
render_app
|
|
.init_resource::<GxGpuCache>()
|
|
.init_resource::<GxTexturePool>()
|
|
.add_systems(bevy::render::RenderStartup, init_pipelines)
|
|
.add_systems(
|
|
Render,
|
|
(
|
|
prepare_static_gx.in_set(RenderSystems::PrepareResources),
|
|
// MONKEY (room gate): before the bind groups, beside the shared light's upload.
|
|
prepare_room_claims.in_set(RenderSystems::PrepareResources),
|
|
prepare_view_bind.in_set(RenderSystems::PrepareBindGroups),
|
|
prepare_torch_bind.in_set(RenderSystems::PrepareBindGroups),
|
|
),
|
|
)
|
|
.add_render_graph_node::<ViewNodeRunner<StaticGxNode>>(Core3d, StaticGxLabel)
|
|
// BEFORE bevy's opaque pass (decision 2016): the retained statics — every building and
|
|
// every steady doodad — are the frame's best early-Z occluders, and terrain's fragment is
|
|
// the frame's dearest (four splat layers, the alpha map, the baked shadow: six samples a
|
|
// pixel). Drawn first, the walls and trunks fill the depth buffer with early writes and
|
|
// the terrain behind them is rejected before it samples anything; drawn after (the order
|
|
// 1429 inherited from "bevy's pass, then ours"), every terrain fragment under a building
|
|
// was shaded in full and then overwritten. The same holds against the entity lane's
|
|
// creatures and animated doodads, which stand in front of nothing static as a rule. The
|
|
// pass takes the depth and colour CLEAR with it (bevy's attachments clear on first use,
|
|
// `DepthAttachment::get_attachment`); when nothing is visible it returns before touching
|
|
// either and the opaque pass clears as before. An immediate-mode GPU (the Steam Deck,
|
|
// every Windows part) is where this counts; a tile-based one (Apple) resolves opaque
|
|
// order in hardware and reads the same frame either way — which is why no measurement
|
|
// of this exists on the rig, and the player journal's `gpu_opaque`/`gpu_static` columns
|
|
// (2008) are where the number lands.
|
|
.add_render_graph_edges(
|
|
Core3d,
|
|
(Node3d::StartMainPass, StaticGxLabel, Node3d::MainOpaquePass),
|
|
);
|
|
}
|
|
|
|
/// Copy the collector's published half into the extractable resource.
|
|
pub(super) fn publish_gx_world(gx: Res<super::StaticGx>, mut out: ResMut<GxWorld>) {
|
|
let _t = super::gx_perf_guard(2);
|
|
// Compare before writing: a parked frame's collector walk produces the same five
|
|
// structures it produced last frame, and an unconditional `clone_from` through `ResMut`
|
|
// both re-cloned them here and marked the resource changed, so the render world cloned
|
|
// the whole set again at extract — 1435's two 0.2 ms rows, paid on every frame that
|
|
// changed nothing (decision 1979). The maps hold `Arc`s, so identity is pointer identity.
|
|
fn same_arcs<K: std::hash::Hash + Eq>(
|
|
a: &HashMap<K, std::sync::Arc<GxCellDraw>>,
|
|
b: &HashMap<K, std::sync::Arc<GxCellDraw>>,
|
|
) -> bool {
|
|
a.len() == b.len()
|
|
&& a.iter()
|
|
.all(|(k, v)| b.get(k).is_some_and(|w| std::sync::Arc::ptr_eq(v, w)))
|
|
}
|
|
let w = &gx.world;
|
|
if !same_arcs(&out.cells, &w.cells) {
|
|
out.cells.clone_from(&w.cells);
|
|
}
|
|
if out.visible != w.visible {
|
|
out.visible.clone_from(&w.visible);
|
|
}
|
|
if !same_arcs(&out.wmos, &w.wmos) {
|
|
out.wmos.clone_from(&w.wmos);
|
|
}
|
|
if !same_arcs(&out.props, &w.props) {
|
|
out.props.clone_from(&w.props);
|
|
}
|
|
if out.visible_wmos != w.visible_wmos {
|
|
out.visible_wmos.clone_from(&w.visible_wmos);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn item(index_range: Range<u32>, cutout: bool) -> GxItemDraw {
|
|
GxItemDraw {
|
|
index_range,
|
|
texture: None,
|
|
cutout,
|
|
two_sided: false,
|
|
vertex_range: 0..0,
|
|
group: None,
|
|
order: 0,
|
|
sidn: [0; 3],
|
|
slot: 0,
|
|
ext_night: false,
|
|
enclosed: false,
|
|
}
|
|
}
|
|
|
|
/// Runs fuse adjacent live items of one (slot, bucket, group); a killed item is dropped
|
|
/// whole and SPLITS the run around it (B3: no vertex work is submitted for killed rows);
|
|
/// a slot or bucket change breaks the run; an all-killed region coalesces to nothing.
|
|
#[test]
|
|
fn runs_fuse_live_items_and_split_at_kills() {
|
|
let draws = vec![
|
|
item(0..3, false),
|
|
item(3..6, false),
|
|
item(6..9, false),
|
|
item(9..12, true), // bucket change
|
|
item(12..15, true),
|
|
];
|
|
let slots = vec![0, 0, 0, 0, 1]; // the last item binds another pool class
|
|
let runs = build_runs(&draws, &slots, &[]);
|
|
assert_eq!(runs.len(), 3, "opaque span fused; cutout split by slot");
|
|
assert_eq!(runs[0].index_range, 0..9);
|
|
assert_eq!(runs[1].index_range, 9..12);
|
|
assert!(runs[1].cutout);
|
|
assert_eq!(runs[2].slot, 1);
|
|
// Kill the middle opaque item: the fused run splits around it.
|
|
let runs = build_runs(&draws, &slots, &[0b010u64]);
|
|
assert_eq!(runs.len(), 4);
|
|
assert_eq!(runs[0].index_range, 0..3);
|
|
assert_eq!(runs[1].index_range, 6..9);
|
|
// Kill everything: nothing is submitted at all.
|
|
assert!(build_runs(&draws, &slots, &[0b11111u64]).is_empty());
|
|
}
|
|
}
|