This commit is contained in:
Jonas H
2026-04-06 20:21:13 +02:00
5 changed files with 842 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
---
name: panopticon
description: >-
Auto-generated project overview for snow_trail. Structure, conventions,
and recent activity. Updated nightly by Panopticon.
---
# snow_trail — Project Overview
Pure Rust retro-aesthetic 3D game using SDL3 windowing, wgpu rendering, and rapier3d physics. Implements a pure ECS architecture with intent-based cross-system communication, state machines via TypeId polymorphism, and compute-shader snow deformation. Content authored in Blender 5.0 (glTF meshes + EXR heightmaps).
## Quick Reference
- **Language:** Rust (stable)
- **Key dependencies:** sdl3, wgpu, rapier3d, glam, bladeink
- **Build:** `cargo build --release`
- **Test:** `cargo test`
- **Entry point:** `src/main.rs::main()`
## Documentation
- [Structure](structure.md) — modules, types, data flow, dependencies
- [Guide](guide.md) — conventions, patterns, anti-patterns, testing
- [Changelog](changelog.md) — recent changes, active areas, stability

View File

@@ -0,0 +1,83 @@
# Changelog
*Last updated: Friday, April 3, 2026*
## Active Areas
| Area | Changes (30d) | Status |
|------|---|---|
| Main loop & systems | 9 churn | Active — intent-based architecture solidifying |
| Dialog system | 10 churn | Active — projectiles, camera, rendering |
| Render core | 15 churn | Active — text pipeline, font atlas, globals |
| World state | 8 churn | Active — intent storage, component management |
| Player mechanics | 6 churn | Stable — character bundle, player states |
| Shader system | 4 churn | Active — gizmo lines, dissolve, snow light |
## Recent Changes
### Intent-Based Architecture
- **Completed major refactor** (3 weeks ago): Systems now communicate through one-frame `Storage<T>` intents instead of direct function calls.
- **Intent types**: `FollowPlayerIntent`, `StopFollowingIntent`, `CameraTransitionIntent`, `SpawnParticleIntent`
- **Consumer pattern**: Systems read intents from world storage, act on them, then remove them. Producers don't import consumer modules.
- **Documented pattern** in `docs/intent-based-architecture.md` with full execution order contract.
### Dialog System Suite
- **Dialog state machine**: Ink-based story progression with display timer, projectile phases, parry mechanics.
- **Projectile system**: Spawns physics-driven projectiles with parry windows; detects hits vs. evasion vs. parries (I/J/L buttons).
- **Dialog camera**: Auto-frames all dialog participants; transitions smoothly between follow-cam and dialog-cam via intent; computes centroid + spread.
- **Bubble rendering**: Billboard-based text with word-wrap, dynamic sizing (tail height → body dimensions), border/fill colors, RGBA text atlas.
### Text & Font Rendering
- **Font atlas pipeline**: Pre-rasterized glyphs (16×8 grid, 124px cells) from Departure Mono font.
- **Text measurement**: Character-width aware layout with line wrapping against max bubble width.
- **Vertex-based text**: Custom `TextVertex` (position, UV) fed into dedicated text pipeline with view-proj uniform.
### Particle System
- **Intent-driven spawning**: `SpawnParticleIntent` queues particle emissions; `particle_intent_system` transfers into persistent `ParticleBuffers`.
- **Configurable emitters**: Burst count, lifetime range, speed range, directional spread (cone), gravity, size range, color gradient.
- **Snow particle integration**: Spawned continuously at camera position for screen-fill effect.
### Render Pipeline
- **Global renderer via thread-local**: `render::global` module centralizes `Device`/`Queue` access for loaders and systems; long-term goal is explicit `RenderContext` params.
- **Gizmo shader**: Fragment shader outputs world-normal as axis color (X=red, Y=green, Z=blue) for entity transform editing.
- **Snow light accumulation**: Persistent texture tracking deformation; blue-noise dithering maps snow brightness into tile pattern (brightness > threshold → dither step).
- **Dissolve shader**: Blue-noise masked alpha cutoff; controlled by `enable_dissolve` uniform.
### Tree Occlusion & Instances
- **Tree dissolve system**: Per-instance dissolve amounts lerp toward targets at configurable speed.
- **Occlusion detection**: Instances between camera and player dissolve based on perpendicular distance to camera-to-player ray.
- **Instance buffer sync**: Tree instances updated into GPU buffer after dissolve state changes.
### Trigger & Event Flow
- **Sphere-based triggers**: Continuous state tracking (Idle ↔ Inside); fires `TriggerEvent` with `Entered`/`Exited` kinds.
- **Activation filtering**: `TriggerFilter::Player` specifies which entity types can activate.
- **Event queue**: Events collected in `world.trigger_events` and consumed by dialog system to spawn bubbles.
### Player State Machine
- **State hierarchy**: Idle, Walking, Jumping, Falling, Leaping (dash), Rolling.
- **Transition logic**: Grounded checks, input presence, airtime duration.
- **Physics per-state**: Damping on enter (Idle), horizontal input application (Walking), vertical velocity control (Jumping).
### Editor & Inspector
- **Dear ImGui integration**: Inspector window with player state display, entity transform gizmos.
- **Entity selection**: Right-click picking; gizmo renders for selected entity when editor active.
- **UI mode gating**: Systems check `editor.wants_keyboard()`/`wants_mouse()` to suppress game input during inspector focus.
## Stability
| Area | Last Changed | Status |
|---|---|---|
| Physics manager | 80+ days | Stable |
| Camera follow | 30 days | Stable — intent system reduced coupling |
| Movement component | 30+ days | Stable |
| Mesh loader | 30+ days | Stable |
| Spotlight sync | 30 days | Stable |
| State machine physics | 30 days | Stable — core transitions unchanged |
| Debug colliders | 30+ days | Stable |
## Open Threads
- **Global renderer thread-local**: Marked for gradual migration to explicit `RenderContext` parameters on systems/loaders. Current implementation works; refactor is low-priority.
- **Text rendering completeness**: Font atlas + text pipeline in place; scaling and color support functional. No known issues.
- **Dialog parry mechanics**: Projectile system complete; integration with Ink outcomes working. Gameplay tuning (PARRY_WINDOW_RADIUS, HIT_RADIUS) ongoing.
- **Tree occlusion performance**: Occlusion system runs every frame; no visibility culling yet. Scalability unknown for >100 trees.

View File

@@ -0,0 +1,354 @@
# Guide
## Conventions
### Naming
- **Bundles**: `*Bundle` suffix (e.g., `PlayerBundle`, `TerrainBundle`)
- **Components**: `*Component` suffix (e.g., `MeshComponent`, `MovementComponent`)
- **States**: Plain names without suffix (e.g., `IdleState`, `WalkingState`); impl `State` trait
- **Intents**: `*Intent` suffix for one-frame events (e.g., `CameraTransitionIntent`, `FollowPlayerIntent`)
- **Systems**: `*_system` suffix for functions (e.g., `player_input_system`, `trigger_system`)
- **Storage containers**: lowercase plural (e.g., `world.transforms`, `world.movements`)
- **Tags** (marker components): bare unit type in `Storage<()>` (e.g., `player_tags`, `bubble_tags`)
### Formatting
- Run `cargo fmt` with `brace_style = "AlwaysNextLine"`, `control_brace_style = "AlwaysNextLine"`
- **NO inline comments** unless absolutely necessary—code must be self-documenting
- Doc comments (`///`) **only** for public APIs and complex algorithms
- **NO inline paths** in code—always use `use` statements at file level
- **NO `use` statements inside functions or impl blocks**—all imports at module level
### Imports
- Group imports: standard library, external crates, internal modules (in that order)
- Use fully-qualified module paths in `use` statements; never nest unnecessarily
- Example:
```rust
use crate::components::{CameraComponent, FollowComponent};
use crate::world::World;
use glam::Vec3;
```
## Patterns
### Intent-Based Communication (One-Frame Queues)
**Why**: Systems don't call each other directly. This decouples producer from consumer and maintains a flat pipeline.
**How**: Intent is a simple struct in a queue (`Vec<T>`). Producer inserts, consumer reads and removes.
```rust
// components/intent.rs
pub struct CameraTransitionIntent {
pub duration: f32,
}
// systems/camera.rs - consumer
pub fn camera_intent_system(world: &mut World) {
let transition_entities: Vec<EntityHandle> = world.camera_transition_intents.all();
for entity in transition_entities {
let duration = world
.camera_transition_intents
.get(entity)
.map(|i| i.duration)
.unwrap_or(0.5);
start_camera_transition(world, entity, duration);
world.camera_transition_intents.remove(entity); // consumed
}
}
```
### Bundle Pattern (Entity Factory)
**Why**: Encapsulate all initialization logic for related components into one spawnable unit.
```rust
// bundles/player.rs
pub struct PlayerBundle {
pub position: Vec3,
}
impl Bundle for PlayerBundle {
fn spawn(self, world: &mut World) -> Result<EntityHandle, String> {
let entity = world.spawn();
// Physics
let rigidbody = RigidBodyBuilder::kinematic_position_based()
.translation(self.position.into())
.build();
let rigidbody_handle = PhysicsManager::add_rigidbody(rigidbody);
world.physics.insert(entity, PhysicsComponent { rigidbody: rigidbody_handle, .. });
// State machine with transitions
let mut state_machine = StateMachine::new::<FallingState>();
state_machine.register_state(|w: &mut World| &mut w.falling_states);
state_machine.add_transition::<FallingState, IdleState>(move |world| {
is_grounded(world, entity_id) && !has_input(world, entity_id)
});
world.state_machines.insert(entity, state_machine);
// Tags
world.player_tags.insert(entity, ());
Ok(entity)
}
}
```
### State Machine with Type-Safe Transitions
**Why**: Encapsulate state logic (on_enter, on_exit, physics_update) and guard transitions with closures.
```rust
// states/player_states.rs - implement State trait
impl State for IdleState {
fn tick_time(&mut self, delta: f32) {
self.time_in_state += delta;
}
fn on_enter(&mut self, world: &mut World, entity: EntityHandle) {
// Apply damping on enter
world.physics.with(entity, |physics| {
PhysicsManager::with_rigidbody_mut(physics.rigidbody, |rb| {
let current_velocity = *rb.linvel();
rb.set_linvel(Vector::new(0.0, current_velocity.y, 0.0), true);
});
});
}
fn on_physics_update(&mut self, world: &mut World, entity: EntityHandle, _delta: f32) {
// Ground snapping
let current_y = world.physics.with(entity, |p| {
PhysicsManager::with_rigidbody_mut(p.rigidbody, |rb| rb.translation().y)
}).flatten();
// ...
}
}
// bundles/player.rs - register transitions
state_machine.add_transition::<IdleState, WalkingState>(move |world| {
world
.inputs
.with(entity_id, |i| i.move_direction.length() > 0.01)
.unwrap_or(false)
});
```
### Storage.with/with_mut Pattern (Closure-Based Access)
**Why**: Avoids holding mutable references across multiple accesses; satisfies borrow checker.
```rust
// systems/spotlight_sync.rs
pub fn spotlight_sync_system(world: &World) -> Vec<Spotlight> {
let mut spotlights = Vec::new();
for entity in world.spotlights.all() {
// Closure captures immutably—safe to call multiple times
if let Some(spotlight_component) = world.spotlights.get(entity) {
if let Some(transform) = world.transforms.get(entity) {
let position = transform.position + spotlight_component.offset;
spotlights.push(Spotlight::new(position, ..));
}
}
}
spotlights
}
// With mutation
world.movements.with_mut(entity, |movement| {
movement.movement_context.is_floored = true;
});
```
### System Function Signature
**Why**: Pass only the storages (or immutable `World`) that the system needs. Makes data dependencies explicit.
```rust
// Good: explicit dependencies
pub fn camera_follow_system(world: &mut World) {
let camera_entities: Vec<_> = world.follows.all();
for camera_entity in camera_entities {
// Access what's needed
if let Some(follow) = world.follows.get(camera_entity) {
world.transforms.with_mut(camera_entity, |t| {
t.position = target_position + new_offset;
});
}
}
}
// For read-only systems
pub fn spotlight_sync_system(world: &World) -> Vec<Spotlight> { .. }
```
### Default Trait for Configuration Components
**Why**: Sensible defaults allow bundle code to be cleaner; override what's needed.
```rust
// components/jump.rs
impl Default for JumpComponent {
fn default() -> Self {
Self {
jump_height: 5.0,
jump_duration: 0.5,
air_control_force: 100.0,
max_air_momentum: 3.0,
air_damping_active: 0.95,
air_damping_passive: 0.9,
jump_curve: CubicBez::new((0.0, 0.0), (0.4, 1.0), (0.6, 1.0), (1.0, 0.0)),
jump_context: JumpContext::default(),
}
}
}
// Usage in bundle
world.jumps.insert(entity, JumpComponent::default());
```
### Physics Closure Pattern
**Why**: Avoids lifetime tangles when borrowing rigidbody from PhysicsManager static storage.
```rust
// states/player_states.rs
world.physics.with(entity, |physics| {
PhysicsManager::with_rigidbody_mut(physics.rigidbody, |rigidbody| {
let vel = *rigidbody.linvel();
rigidbody.set_linvel(Vector::new(vel.x, 0.0, vel.z), true);
});
});
```
## Anti-Patterns
### Direct System-to-System Calls
**Don't do this:**
```rust
// ❌ Bad: tightly coupled, hard to debug
fn system_a(world: &mut World) {
system_b_logic(world); // Hidden dependency
}
```
**Do this instead:**
```rust
// ✅ Good: intent in queue, flat pipeline
pub fn system_a(world: &mut World) {
world.some_intents.insert(entity, MyIntent { .. });
}
pub fn system_b(world: &mut World) {
for entity in world.some_intents.all() {
// process
world.some_intents.remove(entity);
}
}
```
### Holding Mutable References Across Multiple Storage Accesses
**Don't do this:**
```rust
// ❌ Bad: won't compile (borrow checker)
let mut movement = world.movements.get_mut(entity).unwrap();
let mut transform = world.transforms.get_mut(entity).unwrap();
movement.foo = transform.position.x;
```
**Do this instead:**
```rust
// ✅ Good: use closures
world.movements.with_mut(entity, |movement| {
world.transforms.with(entity, |transform| {
movement.foo = transform.position.x;
});
});
```
### Inline Paths in Code
**Don't do this:**
```rust
// ❌ Bad
let velocity = *crate::physics::PhysicsManager::with_rigidbody_mut(..);
```
**Do this instead:**
```rust
// ✅ Good: use statements at top
use crate::physics::PhysicsManager;
// ... in code
let velocity = *PhysicsManager::with_rigidbody_mut(..);
```
### Global or Context-Based Component Queries
**Don't do this:**
```rust
// ❌ Bad: loses intent, repeats queries
for entity in world.meshes.all() {
if let Some(mesh) = world.meshes.get(entity) { .. }
}
for entity in world.meshes.all() {
if let Some(mesh) = world.meshes.get(entity) { .. }
}
```
**Do this instead:**
```rust
// ✅ Good: query once, iterate clearly
let entities: Vec<_> = world.meshes.all();
for entity in entities {
if let Some(mesh_comp) = world.meshes.get(entity) {
// process
}
}
```
### Overly Generic (World/&mut World) Parameters
**Don't do this:**
```rust
// ❌ Bad: implicit dependencies
fn update_position(world: &mut World, entity: EntityHandle) {
// What do we actually need?
}
```
**Do this instead:**
```rust
// ✅ Good: explicit dependencies
fn update_position(
transforms: &mut Storage<Transform>,
entity: EntityHandle,
) {
transforms.with_mut(entity, |t| t.position.y += 1.0);
}
```
## Testing
### Structure
Tests are inline with source (`#[cfg(test)] mod tests`). Focus on:
- Component initialization and state transitions
- Bundle spawning and validation
- Physics helper calculations
- Intent queue ordering
### What to Test
- **State transitions**: Verify conditions gate transitions correctly
```rust
#[test]
fn test_idle_to_walking_transition() {
let world = setup_test_world();
world.inputs.with_mut(player, |i| i.move_direction = Vec3::X);
assert!(should_transition_to_walking(&world, player));
}
```
- **Bundle spawning**: Ensure all components are inserted
- **Default values**: Verify sensible defaults in `Default` impls
- **Physics calculations**: Unit-test slope calculations, terrain queries
### Running Tests
```bash
cargo test
cargo test --lib # Unit tests only
cargo test -- --nocapture # Show output
```
## References
- See **CLAUDE.md** in project root for authoritative style rules (no inline comments, doc comments for public APIs only)
- See **docs/self-gating-systems.md** for detailed intent-based pipeline patterns
- See `src/states/player_states.rs` for canonical State impl examples
- See `src/bundles/player.rs` for complete Bundle pattern with state machine setup

View File

@@ -0,0 +1,163 @@
# Structure
## Modules
### `src/entity.rs`
Manages entity lifecycle with `EntityHandle` (u64 alias) and `EntityManager` tracking alive entities. Entities are opaque IDs; all data lives in component storages on `World`.
### `src/world.rs`
Core ECS container holding `EntityManager`, 20+ typed `Storage<T>` collections (one per component type), and intent queues (`follow_player_intents`, `stop_following_intents`, `camera_transition_intents`). One-frame intents are inserted and consumed within a single frame. World also holds singleton state: `snow_layer`, `debug_mode`, `gizmo_mesh`.
### `src/components/`
Component types define entity data. Key storages:
- **Transforms** (`Transform`): position, rotation, scale
- **Physics** (`PhysicsComponent`): rapier3d rigidbody/collider handles
- **Movement** (`MovementComponent`): walking speed, acceleration, damping state
- **Jump** (`JumpComponent`): jump height, air control, jump curve
- **State machines** (stored per-entity): `IdleState`, `WalkingState`, `JumpingState`, `FallingState`, `LeapingState`, `RollingState`
- **Input** (`InputComponent`): current move direction, jump/parry key states
- **Camera** (`CameraComponent`): FOV, aspect, yaw/pitch; `CameraTransition` for animated transitions
- **Dialog** (`DialogBubbleComponent`, `DialogProjectileComponent`, `DialogSourceComponent`): Ink story state, projectile tracking, outcome events
- **Rendering** (`MeshComponent`): mesh reference, pipeline, instance buffer, dissolve/snow-light flags
- **Misc**: `FollowComponent`, `RotateComponent`, `DissolveComponent`, `TriggerComponent`, `ParticleEmitterConfig`
### `src/states/state.rs`
Per-entity state machine: `StateMachine` holds current `TypeId`, registered state types, and transitions. `State` trait defines lifecycle (`on_enter`, `on_physics_update`, `on_exit`). Transitions are condition predicates checked each update. Player entity uses this for locomotion states.
### `src/systems/`
Flat list of update functions called in main loop. No cross-system coupling; all communication via world state and intents.
- **Camera**: `camera_input_system` → generates intents; `camera_intent_system` consumes them; `camera_follow_system` (follows player), `camera_noclip_system`, `camera_transition_system`, `camera_ground_clamp_system`
- **Input**: `player_input_system` reads SDL3 `InputState`, writes `InputComponent`
- **Physics**: `state_machine_physics_system` (fixed-step), `PhysicsManager::physics_step()`, `physics_sync_system` (copies rapier bodies back to transforms), `trigger_system` (AABB overlap → events)
- **Dialog**: `dialog_system` (ticks story state), `dialog_projectile_system` (moves projectiles), `dialog_camera_system` (focuses camera on speaker), `dialog_bubble_render_system` (generates billboard/text draw calls)
- **Rendering**: `render_system` collects `DrawCall` from meshes/transforms; `spotlight_sync_system` syncs light positions to shader uniform
- **State machine**: `state_machine_system` (per-frame), `state_machine_physics_system` (fixed-step) tick state lifecycle
- **Trees**: `tree_occlusion_system` (culls trees behind camera), `tree_dissolve_update_system` (animates dissolve), `tree_instance_buffer_update_system` (writes GPU buffer)
- **Snow**: `snow_system` deforms snow layer at physics contacts; `particle_intent_system`/`particle_update_system` manage particle emitters
- **Rotate**: `rotate_system` rotates entities by delta
### `src/bundles/`
Factory functions to spawn pre-configured entity groups:
- `PlayerBundle`: player character with all locomotion components
- `TestCharBundle`: test NPC
- `CameraBundle`: camera entity
- `TerrainBundle`: terrain mesh + collider
- `SpotlightBundle` + `spawn_spotlights()`: light entities from scene data
### `src/render/`
GPU rendering pipeline via wgpu. Singleton `Renderer` stored in thread-local (global.rs).
- **`Renderer`**: wgpu device/queue/surface, framebuffer, pipelines, bind groups, shadow map texture, spotlight data
- **`DrawCall`** (types.rs): vertex/index buffers, model matrix, pipeline enum, instance buffer, entity ref
- **Pipelines** (pipeline.rs): `create_main_pipeline()`, `create_snow_clipmap_pipeline()`, `create_wireframe_pipeline()`, `create_shadow_pipeline()`, `create_debug_lines_pipeline()`
- **`Uniforms`** (types.rs): model/view/projection, 4 spotlights, camera position, height scale, player position, time, tile scale, debug mode
- **Shadow**: `render_shadow_pass()` renders scene depth to shadow map from each spotlight
- **Snow**: `SnowLayer` deforms snow heightfield via compute; `ClipmapConfig` manages multi-level clipmap grid; `deform_at_position()` marks terrain changed
- **Snow light**: `SnowLightAccumulation` ping-pong texture accumulates light contributions from spotlights onto snow surface
- **Billboard pipeline** + **Text pipeline**: render dialog bubbles and text overlays
- **Particle pipeline**: billboarded particles with per-instance color/velocity
- **Font atlas**: pre-rasterized glyph texture from embedded font file
### `src/loaders/`
Load scene data from glTF files:
- **`scene.rs`** `Space::load_space()`: loads meshes, lights, player spawn, test char spawn from single glTF
- **`mesh.rs`** `Mesh::load_gltf_with_instances()`: parses glTF buffers, vertex/index data, multi-instance mesh batches; `InstanceData` (position/rotation/scale/dissolve); `Vertex` (position/normal/UV)
- **`lights.rs`**: extracts spotlight transforms/params from glTF nodes
- **`empty.rs`**: extracts named empty (spawn point) transforms from glTF
- **`heightmap.rs`**: loads EXR heightfield texture for terrain collision
- **`terrain.rs`**: builds rapier heightfield collider from EXR matrix
### `src/physics.rs`
Thread-local `PhysicsManager` wrapping rapier3d. `physics_step()` runs one integration step; `add_rigidbody()`, `add_collider()` register bodies; `raycast()` queries. `HeightfieldData` caches terrain height matrix.
### `src/utility/`
- **`transform.rs`** `Transform`: matrix conversions to/from nalgebra `Isometry3`; getters/setters for position/rotation/scale
- **`input.rs`** `InputState`: SDL3 key states (WASD, Space, Shift, Ctrl) and mouse delta; `handle_event()` updates state; `clear_just_pressed()` resets one-frame flags
- **`time.rs`**: `Time::get_time_elapsed()` returns seconds since init (static Instant)
### `src/debug/`
- **`mode.rs`** `DebugMode` enum: None, Normals, UV, Depth, Wireframe, Colliders, ShadowMap, SnowLight; `cycle()` steps through
- **`collider_debug.rs`**: renders rapier collider AABBs as line meshes
- **`gizmo.rs`**: renders 3D transform gizmo (position/rotation/scale) for editor
### `src/editor/`
- **`inspector.rs`** `Inspector`: wraps Dear ImGui context; `render()` draws frame to texture; `build_ui()` draws entity inspector panels
- **`mod.rs`** `EditorState`: manages editor active state, selected entity, mouse capture; `editor_loop()` calls inspector, handles picking
### `src/picking.rs`, `src/postprocess.rs`, `src/texture.rs`, `src/paths.rs`
Utility modules: ray casting for mouse pick, fullscreen blit/framebuffer downsampling, dither/flowmap texture loading, paths to asset files.
## Data Flow
1. **Initialization** (`main.rs` `init()`): SDL3 window → wgpu renderer (Vulkan) → World creation → load scene (Space from glTF) → spawn bundles (player, terrain, camera, lights) → initialize physics
2. **Main loop** (`main.rs` `main()` with 60 Hz fixed physics + variable-rate graphics):
- **Per-frame** (delta): Input events → `player_input_system` (fills `InputComponent`) + `camera_input_system` (generates intents)
- **Intent processing**: `camera_intent_system` consumes intents, updates `CameraComponent`/`CameraTransition`
- **Camera systems**: follow player, noclip, transition, clamp to ground
- **Fixed physics** (1/60s accumulator):
- `state_machine_physics_system`: tick state's `on_physics_update()`
- `PhysicsManager::physics_step()`: rapier integration
- `physics_sync_system`: copy rigidbody poses to `Transform`
- `trigger_system`: detect collisions, emit events
- `dialog_system`, `dialog_projectile_system`: Ink story tick, projectile movement
- **Per-frame systems**: `state_machine_system` (non-physics update), rotate, particle, tree dissolve, snow deformation, spotlight sync
- **Render collection**: `render_system``Vec<DrawCall>` from all meshes; snow layer adds clipmap draw calls; debug adds collider/gizmo calls
- **Submission**: `submit_frame()` renders draw calls, dialog bubbles/text, particles to framebuffer; blit to screen; optional ImGui overlay
3. **Frame cleanup**: `InputState::clear_just_pressed()` (flags reset for next frame)
## Key Types
| Type | Module | Description |
|------|--------|-------------|
| `EntityHandle` | entity | u64 opaque entity ID |
| `Storage<T>` | world | HashMap storage for per-entity component data |
| `World` | world | ECS container: entities, 20+ storages, intent queues, singleton state |
| `Transform` | utility/transform | Position (Vec3), rotation (Quat), scale (Vec3); matrix conversions |
| `StateMachine` | states/state | Per-entity state machine: current state TypeId, registered states, transitions |
| `State` trait | states/state | Lifecycle: `on_enter`, `on_physics_update`, `on_exit`, `on_update` |
| `PhysicsComponent` | components/physics | Rapier3d rigidbody + optional collider handles |
| `MovementComponent` | components/movement | Walking speed, acceleration, damping, context (floored, last floor time) |
| `JumpComponent` | components/jump | Jump height, duration, air control, context (in progress, origin height) |
| `CameraComponent` | components/camera | FOV, aspect, yaw/pitch angles, is_active flag |
| `InputComponent` | components/input | Current frame: move direction, jump/parry key states (flags) |
| `MeshComponent` | components/mesh | Mesh ref, pipeline enum, instance buffer, dissolve/snow-light flags |
| `DialogBubbleComponent` | components/dialog | Ink story, current text, dialog phase (displaying/projectile in flight), parry button |
| `DrawCall` | render/types | GPU command: vertex/index buffers, model matrix, pipeline, instance count, entity ID |
| `Uniforms` | render/types | Per-frame shader data: matrices, spotlight array, debug flags |
| `Renderer` | render/mod | GPU state: device, queue, surface, all pipelines, framebuffer, texture samplers |
| `Space` | loaders/scene | Loaded scene: mesh batches, spotlight data, spawn positions |
| `Mesh` | loaders/mesh | GPU vertex/index buffers, AABB, CPU vertex data for physics |
| `SnowLayer` | render/snow | Snow heightfield: deform bind groups, depth texture, clipmap grid levels |
| `SnowLightAccumulation` | render/snow_light | Ping-pong textures + pipeline accumulating spotlight contributions onto snow |
| `InputState` | utility/input | SDL3 event state: key flags, mouse delta, relative mode |
| `PhysicsManager` | physics | Rapier3d bodies/colliders/pipeline; thread-local singleton |
| `DebugMode` | debug/mode | Enum: None, Normals, UV, Depth, Wireframe, Colliders, ShadowMap, SnowLight |
## Entry Points
1. **`main()` in src/main.rs**
- Calls `init()` → initializes SDL3, wgpu, loads world from scene glTF, spawns entities
- Runs infinite loop: event poll → systems (camera/input/physics/render) → frame submit → sleep to 60 Hz
2. **`Game::init()` in src/main.rs**
- SDL3 window + Vulkan surface
- `Renderer::new()` initializes all GPU pipelines and textures
- `init_world()` loads space, spawns bundles, sets up terrain/snow/lights
3. **`World::new()` in src/world.rs**
- Creates empty storages for all 20+ component types and intent queues
## Dependencies
- **Engine**: wgpu (GPU), rapier3d (physics), glam (math), nalgebra (nalgebra for physics conversions), SDL3 (windowing/input)
- **Content**: bladeink (Ink story scripting), image crate (EXR heightmaps), gltf (scene loading)
- **Editor**: Dear ImGui via imgui crate + SDL3 integration
- **Internal**: All systems read/write `World`; systems are called in fixed sequence from main loop; no circular dependencies
- **Thread-local singletons**: `Renderer` (render/global.rs), `PhysicsManager` (physics.rs), `GLOBAL_RENDERER`, `GLOBAL_PHYSICS`
## Initialization Order
1. SDL3 init → window creation → Vulkan adapter/device
2. `Renderer::new()` → wgpu device/queue, create all pipelines, load textures (dither, flowmap, font atlas, shadow map, blue noise)
3. Load scene from glTF → meshes, lights, spawns
4. Spawn bundles: player (with input/movement/jump/state machine), terrain (mesh + heightfield collider), camera (follows player), lights (spotlights)
5. Initialize snow layer (deform by tree positions)
6. Initialize snow light accumulation (bind to spotlight data)
7. Main loop: process events → run systems in sequence → submit frame

View File

@@ -0,0 +1,218 @@
bl_info = {
"name": "Snow Trail Export",
"author": "Snow Trail",
"version": (1, 0, 0),
"blender": (5, 0, 0),
"location": "3D Viewport > Sidebar > Snow Trail",
"description": "One-click glTF export to project assets folder",
"category": "Import-Export",
}
import bpy
from pathlib import Path
def find_project_root():
blend_path = Path(bpy.data.filepath)
if not blend_path.exists():
return None
candidate = blend_path.parent
while candidate != candidate.parent:
if (candidate / "assets").is_dir() and (candidate / "blender").is_dir():
return candidate
candidate = candidate.parent
return None
def get_export_path():
blend_path = Path(bpy.data.filepath)
project_root = find_project_root()
if project_root is None:
return None
stem = blend_path.stem
return project_root / "assets" / "meshes" / f"{stem}.gltf"
class SNOWTRAIL_OT_export_gltf(bpy.types.Operator):
bl_idname = "snow_trail.export_gltf"
bl_label = "Export to Project"
bl_description = "Export selected objects as glTF to assets/meshes/"
bl_options = {'REGISTER'}
def execute(self, context):
if not bpy.data.filepath:
self.report({'ERROR'}, "Save the .blend file first")
return {'CANCELLED'}
export_path = get_export_path()
if export_path is None:
self.report({'ERROR'}, "Could not find project root (looking for assets/ + blender/ dirs)")
return {'CANCELLED'}
export_path.parent.mkdir(parents=True, exist_ok=True)
selected = [obj for obj in context.selected_objects if obj.type == 'MESH']
if not selected:
self.report({'ERROR'}, "No mesh objects selected")
return {'CANCELLED'}
props = context.scene.snow_trail_export
bpy.ops.export_scene.gltf(
filepath=str(export_path),
export_format='GLTF_SEPARATE',
use_selection=True,
export_apply=props.apply_modifiers,
export_yup=True,
export_texcoords=True,
export_normals=True,
export_colors=props.export_vertex_colors,
export_materials='NONE' if not props.export_materials else 'EXPORT',
export_animations=props.export_animations,
)
rel_path = export_path.relative_to(find_project_root())
self.report({'INFO'}, f"Exported → {rel_path}")
return {'FINISHED'}
class SNOWTRAIL_OT_export_heightmap(bpy.types.Operator):
bl_idname = "snow_trail.export_heightmap"
bl_label = "Bake Heightmap"
bl_description = "Run heightmap bake script for selected terrain"
bl_options = {'REGISTER'}
def execute(self, context):
project_root = find_project_root()
if project_root is None:
self.report({'ERROR'}, "Could not find project root")
return {'CANCELLED'}
script_path = project_root / "blender" / "scripts" / "generate_heightmap.py"
if not script_path.exists():
self.report({'ERROR'}, f"Script not found: {script_path}")
return {'CANCELLED'}
import importlib.util
spec = importlib.util.spec_from_file_location("generate_heightmap", str(script_path))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
terrain_obj = context.active_object
if terrain_obj is None or terrain_obj.type != 'MESH':
self.report({'ERROR'}, "Select a mesh object first")
return {'CANCELLED'}
output_path = project_root / "assets" / "textures" / "terrain_heightmap.exr"
mod.bake_heightmap(
terrain_obj=terrain_obj,
resolution=context.scene.snow_trail_export.heightmap_resolution,
output_path=str(output_path),
)
self.report({'INFO'}, f"Heightmap → assets/textures/terrain_heightmap.exr")
return {'FINISHED'}
class SNOWTRAIL_ExportProperties(bpy.types.PropertyGroup):
apply_modifiers: bpy.props.BoolProperty(
name="Apply Modifiers",
default=True,
description="Apply modifiers before export",
)
export_vertex_colors: bpy.props.BoolProperty(
name="Vertex Colors",
default=True,
description="Export vertex colors",
)
export_materials: bpy.props.BoolProperty(
name="Materials",
default=False,
description="Export materials (usually not needed for retro aesthetic)",
)
export_animations: bpy.props.BoolProperty(
name="Animations",
default=False,
description="Export animations",
)
heightmap_resolution: bpy.props.IntProperty(
name="Heightmap Resolution",
default=1024,
min=128,
max=4096,
description="Resolution for heightmap bake",
)
class SNOWTRAIL_PT_export_panel(bpy.types.Panel):
bl_label = "Snow Trail Export"
bl_idname = "SNOWTRAIL_PT_export_panel"
bl_space_type = 'VIEW_3D'
bl_region_type = 'UI'
bl_category = "Snow Trail"
def draw(self, context):
layout = self.layout
props = context.scene.snow_trail_export
export_path = get_export_path()
if export_path:
project_root = find_project_root()
rel = export_path.relative_to(project_root)
layout.label(text=f"Target: {rel}", icon='FILE')
elif not bpy.data.filepath:
layout.label(text="Save .blend file first!", icon='ERROR')
else:
layout.label(text="Project root not found!", icon='ERROR')
layout.separator()
box = layout.box()
box.label(text="glTF Options:", icon='MESH_DATA')
box.prop(props, "apply_modifiers")
box.prop(props, "export_vertex_colors")
box.prop(props, "export_materials")
box.prop(props, "export_animations")
selected_meshes = [o for o in context.selected_objects if o.type == 'MESH']
row = layout.row()
row.scale_y = 1.5
row.operator("snow_trail.export_gltf", icon='EXPORT')
if not selected_meshes:
row.enabled = False
if selected_meshes:
layout.label(text=f"{len(selected_meshes)} mesh(es) selected")
else:
layout.label(text="Select mesh objects to export", icon='INFO')
layout.separator()
box = layout.box()
box.label(text="Terrain Tools:", icon='WORLD')
box.prop(props, "heightmap_resolution")
box.operator("snow_trail.export_heightmap", icon='IMAGE_DATA')
classes = (
SNOWTRAIL_ExportProperties,
SNOWTRAIL_OT_export_gltf,
SNOWTRAIL_OT_export_heightmap,
SNOWTRAIL_PT_export_panel,
)
def register():
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.Scene.snow_trail_export = bpy.props.PointerProperty(type=SNOWTRAIL_ExportProperties)
def unregister():
del bpy.types.Scene.snow_trail_export
for cls in reversed(classes):
bpy.utils.unregister_class(cls)
if __name__ == "__main__":
register()