Compare commits

..

24 Commits

Author SHA1 Message Date
afff34fff4 docs: panopticon auto-update for snow_trail 2026-04-07 03:02:29 +02:00
Jonas H
b1f3d3be23 Merge branch 'main' of https://gitea.haugesenspil.dk/jonas/snow_trail 2026-04-06 20:21:13 +02:00
Jonas H
d737568b3e all systems updated to take parameters rather than world 2026-04-06 20:21:07 +02:00
fef855d951 blender export plugin 2026-04-05 09:30:12 +02:00
ee3c5b40a4 panopticon skill 2026-04-05 09:29:49 +02:00
Jonas H
87177ad97d intent based architecture 2026-04-02 20:01:07 +02:00
Jonas H
b0022ad17b gizmo shader 2026-04-02 19:57:31 +02:00
Jonas H
d37a3c87e3 terrain test char pos 2026-04-02 19:57:09 +02:00
Jonas H
16626cc277 gizmo 2026-04-02 19:56:01 +02:00
Jonas H
2846c04765 velocity for projectiles 2026-04-02 19:55:50 +02:00
Jonas H
dffd731b87 particles 2026-04-02 16:54:23 +02:00
Jonas H
909ae8612a text fixing 2026-03-29 10:47:10 +02:00
Jonas H
75a046d92a intent refactor 2026-03-28 13:24:05 +01:00
Jonas H
c8142708f5 +1 to text rendering 2026-03-28 13:23:51 +01:00
Jonas H
6b475825c2 +1 2026-03-28 13:23:42 +01:00
Jonas H
5c94bb34d5 agent update 2026-03-28 13:23:36 +01:00
Jonas H
e558b682e2 text rendering 2026-03-28 13:23:27 +01:00
Jonas H
dcd40ae443 ++1 2026-03-28 11:58:09 +01:00
Jonas H
3da031adc2 +1 2026-03-28 11:57:54 +01:00
Jonas H
9e8213da04 more reorganizing 2026-03-28 11:24:11 +01:00
Jonas H
e6c8c259e7 refactor 2026-03-28 11:16:06 +01:00
Jonas H
a79c824540 +1 2026-03-28 11:15:48 +01:00
Jonas H
1ad7b94386 agent update 2026-03-28 11:15:41 +01:00
Jonas H
d21a467878 claude md update 2026-03-28 11:09:02 +01:00
65 changed files with 4559 additions and 1182 deletions

View File

@@ -1,32 +0,0 @@
---
name: explore
description: Explore the codebase to answer architecture questions, locate files, and understand how systems interact. Use this before reading files in the main context. Returns targeted file paths and concise context. Examples: "where does the rule system read from?", "what storages does drag_system touch?", "how does level loading work?"
model: claude-haiku-4-5-20251001
tools:
- mcp__plugin_qmd_qmd__query
- mcp__plugin_qmd_qmd__get
- mcp__opty__opty_query
- mcp__opty__opty_ast
- Glob
- Grep
- Read
---
You are a codebase exploration agent for the snow trail project. Your job is to answer questions about the codebase as concisely as possible.
## Priority order for information sources
1. **QMD first** — search the `brain-project` collection with `mcp__plugin_qmd_qmd__query` using lex/vec/hyde sub-queries. Best for architecture and design patterns.
2. **Opty** — use `mcp__opty__opty_query` for semantic code search (finding functions, types, system interactions). Use `mcp__opty__opty_ast` for exploring file structure and dependencies.
3. **Glob/Grep** — when you need exact pattern matching or file location by name.
4. **Read** — only read specific files when you need precise detail (e.g. function signatures, exact field names). Prefer small files or targeted line ranges.
## Output format
Return a compact summary with:
- The direct answer to the question
- Relevant `file:line` references for anything the caller will need to edit
- No code blocks unless a snippet is essential to the answer
- No re-stating of what you searched — just the findings
Do not read entire large files. If you need to confirm a type or function signature, use Grep to find the definition line, then Read a narrow range around it.

View File

@@ -1,24 +0,0 @@
# Build Commands
## Desktop
```bash
cargo build # debug
cargo build --release # release
cargo run # game mode
cargo run -- --editor # editor mode
```
## iOS
iOS builds require macOS. The project uses a custom SDL3 + wgpu iOS export pipeline. See `brain-project/ios/readme.md` in QMD for the full export guide.
## Android
```bash
cargo apk build
```
## Checks
```bash
cargo check
cargo fmt
cargo clippy
```

View File

@@ -1,4 +1,8 @@
# WGSL Uniform Buffer Alignment
# WGSL Shader Development
Reference for WGSL shader development, buffer alignment, uniform struct layout, and shader asset management. Load this skill when working on shader code, graphics pipelines, or buffer alignment issues.
## WGSL Uniform Buffer Alignment
When creating uniform buffers for WGSL shaders, struct fields must be aligned:
@@ -12,4 +16,6 @@ When creating uniform buffers for WGSL shaders, struct fields must be aligned:
Use padding fields to match WGSL struct layout exactly. Prefer `vec4` over individual floats to avoid alignment issues.
## Shader Organization
Shaders live in `src/shaders/` and are embedded via `include_str!()`.

30
.pi/settings.local.json Normal file
View File

@@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"Bash(cargo check:*)",
"Bash(cargo build:*)",
"Bash(cargo fmt:*)",
"Bash(head:*)",
"mcp__plugin_qmd_qmd__deep_search",
"mcp__plugin_qmd_qmd__query",
"mcp__opty__opty_status",
"mcp__opty__opty_query",
"mcp__opty__opty_ast",
"Bash(cargo search:*)",
"Bash(cargo info:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/filter-cargo-warnings.py\""
}
]
}
]
}
}

View File

@@ -0,0 +1,15 @@
---
name: intent-based-architecture
description: Intent-based architecture patterns for the snow_trail_sdl game engine. Load when adding cross-system communication, new systems to the main loop, changing game mode logic (editor/dialog/gameplay), or working with camera, input, or mode-switching. Systems communicate through typed intent queues — no direct cross-system function calls.
---
# Intent-Based Architecture Skill
Read the architecture document at `docs/intent-based-architecture.md` (resolve relative to the project root `/home/jonas/projects/snow_trail_sdl/`) and follow its patterns when:
- Adding cross-system communication (use intents, not direct function calls)
- Adding new systems to the main loop
- Changing how game modes work (editor, dialog, gameplay)
- Working with camera, input, or mode-switching logic
Key rule: **systems communicate through typed intent data in shared queues** — producers insert intents, consumers process and remove them. No system calls another system's functions directly.

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,97 @@
# Changelog
*Last updated: Tuesday, April 7, 2026*
## Active Areas
| Area | Changes (30d) | Status |
|------|---|---|
| Main loop & systems | 9 churn | Active — explicit storage parameters |
| 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 |
| Content pipeline | 1 churn | New — Blender export addon |
## Recent Changes
### System Signature Refactor (Major)
- **Completed**: All 16 systems refactored to take explicit `Storage<T>` parameters instead of `&World`/`&mut World`.
- **Pattern**: Functions receive only storages they need (e.g., `camera_follow_system` takes `&Storage<FollowComponent>`, `&Storage<CameraComponent>`, `&mut Storage<Transform>`).
- **Benefit**: Explicit data dependencies improve borrow checker clarity and code readability.
- **Main loop** (`src/main.rs`): Updated to pass individual storage fields at call sites rather than whole `World` reference.
- **Affected systems**: `camera_input_system`, `camera_intent_system`, `camera_follow_system`, `camera_noclip_system`, `camera_ground_clamp_system`, `dialog_camera_transition_system`, `dialog_camera_system`, `player_input_system`, `physics_sync_system`, `render_system`, `particle_intent_system`, `particle_update_system`, `trigger_system`, `dialog_system`, `dialog_projectile_system`, all state machines.
### Intent-Based Architecture
- **Completed major refactor** (3 weeks ago): Systems 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.
### 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**: `particle_intent_system` reads `spawn_particle_intents` Vec, transfers configs 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; `spawn_snow_particles` adds particles to `ParticleBuffers`.
### Render Pipeline
- **Global renderer via thread-local**: `render::global` module centralizes `Device`/`Queue` access for loaders and systems.
- **Draw call generation**: `render_system` reads `entities`, `transforms`, `meshes`, `dissolves` storages; generates `DrawCall` per visible entity.
- **Gizmo shader**: Fragment shader outputs world-normal as axis color (X=red, Y=green, Z=blue).
- **Snow light accumulation**: Persistent texture tracking deformation; blue-noise dithering maps snow brightness into tile pattern.
- **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; updated via `tree_occlusion_system` which reads player transforms and cameras.
- **Instance buffer sync**: `tree_instance_buffer_update_system` syncs dissolve state changes to GPU buffer after state updates.
### Trigger & Event Flow
- **Sphere-based triggers**: `trigger_system` runs each fixed step; reads `triggers`, `transforms`, `player_tags` storages; 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.
- **Physics per-state**: `state_machine_physics_system` applies damping on enter (Idle), horizontal input application (Walking), vertical velocity control (Jumping).
- **Transition logic**: Grounded checks, input presence, airtime duration.
### Editor & Inspector
- **Dear ImGui integration**: Inspector window with player state display, entity transform gizmos.
- **Entity selection**: Right-click picking calls `picking::pick_entity` with ray; 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.
### Blender Export Addon
- **Plugin**: `blender/addons/snow_trail_export.py` provides one-click glTF + EXR export from Blender 5.0.
- **glTF operator** (`SNOWTRAIL_OT_export_gltf`): Exports selected mesh objects to `assets/meshes/{blend_filename}.gltf` with configurable modifiers, vertex colors, materials.
- **Heightmap bake operator** (`SNOWTRAIL_OT_export_heightmap`): Calls `generate_heightmap.py` to bake terrain Z-position as 32-bit EXR to `assets/textures/terrain_heightmap.exr` (1024px default).
- **UI panel**: "Snow Trail" category in 3D viewport; auto-discovers project root by walking directory tree until `assets/` and `blender/` found.
## Stability
| Area | Last Changed | Status |
|---|---|---|
| Physics manager | 80+ days | Stable |
| Camera follow | 30 days | Stable — now takes explicit storage params |
| Movement component | 30+ days | Stable |
| Mesh loader | 30+ days | Stable |
| Spotlight sync | 30 days | Stable — explicit storage params applied |
| State machine physics | 30 days | Stable |
| Debug colliders | 30+ days | Stable |
| System signatures | New | Stable — refactor complete, no behavioral changes |
## Open Threads
- **Global renderer thread-local**: Marked for gradual migration to explicit `RenderContext` parameters on systems/loaders.
- **Dialog parry mechanics**: 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.
- **Heightmap bake normalization**: Exported EXR uses raw Z-coordinate; game may need per-level scale application.

View File

@@ -0,0 +1,362 @@
# 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(
follow_player_intents: &mut Storage<FollowPlayerIntent>,
stop_following_intents: &mut Storage<StopFollowingIntent>,
camera_transition_intents: &mut Storage<CameraTransitionIntent>,
follows: &mut Storage<FollowComponent>,
transforms: &mut Storage<Transform>,
cameras: &mut Storage<CameraComponent>,
player_tags: &Storage<()>,
camera_transitions: &mut Storage<CameraTransition>,
) {
let transition_entities: Vec<EntityHandle> = camera_transition_intents.all();
for entity in transition_entities {
let duration = camera_transition_intents
.get(entity)
.map(|i| i.duration)
.unwrap_or(0.5);
start_camera_transition(camera_transitions, transforms, cameras, entity, duration);
camera_transition_intents.remove(entity);
}
}
```
### 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(spotlights: &Storage<SpotlightComponent>, transforms: &Storage<Transform>) -> Vec<Spotlight> {
let mut result = Vec::new();
for entity in spotlights.all() {
if let Some(spotlight_component) = spotlights.get(entity) {
if let Some(transform) = transforms.get(entity) {
let position = transform.position + spotlight_component.offset;
result.push(Spotlight::new(position, ..));
}
}
}
result
}
// With mutation
world.movements.with_mut(entity, |movement| {
movement.movement_context.is_floored = true;
});
```
### System Function Signature (Explicit Storage Parameters)
**Why**: Pass only the specific storages (or read-only `&World`) that the system needs. Makes data dependencies explicit for the reader and borrow checker. **This is the standard pattern—all systems follow this.**
```rust
// Good: explicit dependencies
pub fn camera_follow_system(
follows: &mut Storage<FollowComponent>,
cameras: &Storage<CameraComponent>,
transforms: &mut Storage<Transform>,
) {
let camera_entities: Vec<_> = follows.all();
for camera_entity in camera_entities {
if let Some(follow) = follows.get(camera_entity) {
if let Some(camera) = cameras.get(camera_entity) {
transforms.with_mut(camera_entity, |t| {
t.position = follow.target_position + new_offset;
});
}
}
}
}
// Read-only system
pub fn spotlight_sync_system(spotlights: &Storage<SpotlightComponent>, transforms: &Storage<Transform>) -> Vec<Spotlight> { .. }
// Dialog system example
pub fn dialog_system(
entities: &mut EntityManager,
trigger_events: &[TriggerEvent],
dialog_sources: &Storage<DialogSourceComponent>,
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
transforms: &mut Storage<Transform>,
names: &mut Storage<String>,
player_tags: &Storage<()>,
projectile_tags: &mut Storage<()>,
dialog_projectiles: &mut Storage<DialogProjectileComponent>,
dialog_outcomes: &mut Vec<DialogOutcomeEvent>,
delta: f32,
) { .. }
```
### 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(cameras: &mut Storage<CameraComponent>) {
cameras.insert(entity, CameraTransitionIntent { .. });
}
pub fn system_b(camera_transitions: &mut Storage<CameraTransition>) {
for entity in camera_transitions.all() {
// process
camera_transitions.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(..);
```
### Overly Generic (World/&mut World) Parameters
**Don't do this:**
```rust
// ❌ Bad: implicit dependencies, breaks borrow checker
pub fn camera_follow_system(world: &mut World) {
// What storages do we actually need?
}
```
**Do this instead:**
```rust
// ✅ Good: explicit, focused dependencies
pub fn camera_follow_system(
follows: &mut Storage<FollowComponent>,
cameras: &Storage<CameraComponent>,
transforms: &mut Storage<Transform>,
) {
// Clear what data is needed
}
```
## 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
- See `src/systems/camera.rs` for canonical system function signatures with explicit storage parameters
- See `src/systems/dialog_system.rs` for complex multi-storage system example

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. Systems receive specific storage parameters (e.g., `&mut Storage<Transform>`, `&Storage<PhysicsComponent>`) instead of `&World`/`&mut World`. This makes data dependencies explicit for both borrow checker and reader. No cross-system coupling; all communication via world state and intents.
- **Camera**: `camera_input_system(cameras, follows, input_state)` → generates intents; `camera_intent_system(follow_player_intents, stop_following_intents, camera_transition_intents, ...)` consumes them; `camera_follow_system(follows, cameras, transforms)`, `camera_noclip_system(cameras, follows, transforms, input_state, delta)`, `camera_transition_system(camera_transitions, transforms, cameras, delta)`, `camera_ground_clamp_system(cameras, follows, transforms)`
- **Input**: `player_input_system(cameras, follows, player_tags, inputs, input_state)` reads SDL3 `InputState`, writes `InputComponent`
- **Physics**: `state_machine_physics_system(&mut World, FIXED_TIMESTEP)` (fixed-step), `PhysicsManager::physics_step()`, `physics_sync_system(entities, physics, transforms)` copies rapier bodies back to transforms, `trigger_system(trigger_events, triggers, transforms, player_tags)` (AABB overlap → events)
- **Dialog**: `dialog_system(entities, trigger_events, dialog_sources, bubble_tags, dialog_bubbles, transforms, names, player_tags, projectile_tags, dialog_projectiles, dialog_outcomes, delta)` ticks story state; `dialog_projectile_system(player_tags, transforms, projectile_tags, dialog_projectiles, spawn_particle_intents, dialog_outcomes, leaping_states, rolling_states, input_state)` moves projectiles; `dialog_camera_system(cameras, transforms, bubble_tags, player_pos, delta)` focuses camera on speaker; `dialog_bubble_render_system(transforms, dialog_bubbles, bubble_tags, camera_pos, view_proj)` generates billboard/text draw calls
- **Rendering**: `render_system(entities, transforms, meshes, dissolves)``Vec<DrawCall>` from all meshes; snow layer adds clipmap draw calls; debug adds collider/gizmo calls; `spotlight_sync_system(spotlights, transforms)` syncs light positions to shader uniform
- **State machine**: `state_machine_system(&mut World, delta)` (per-frame), `state_machine_physics_system(&mut World, FIXED_TIMESTEP)` (fixed-step) tick state lifecycle
- **Trees**: `tree_occlusion_system(player_tags, transforms, cameras, tree_instances)` culls trees behind camera; `tree_dissolve_update_system(tree_instances, delta)` animates dissolve; `tree_instance_buffer_update_system(tree_instances)` writes GPU buffer
- **Snow**: `snow_system(cameras, transforms, player_tags, follows, snow_layer)` deforms snow layer at physics contacts; `particle_intent_system(particle_buffers, spawn_particle_intents)`/`particle_update_system(particle_buffers, delta)` manage particle emitters
- **Rotate**: `rotate_system(rotates, transforms, delta)` 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(cameras, follows, player_tags, inputs, input_state)` (fills `InputComponent`) + `camera_input_system(cameras, follows, input_state)` (generates intents)
- **Intent processing**: `camera_intent_system(follow_player_intents, stop_following_intents, camera_transition_intents, follows, transforms, cameras, player_tags, camera_transitions)` consumes intents, updates `CameraComponent`/`CameraTransition`
- **Camera systems**: `camera_follow_system(follows, cameras, transforms)` follows player; `camera_noclip_system(cameras, follows, transforms, input_state, delta)` noclip mode; `camera_transition_system(camera_transitions, transforms, cameras, delta)` animated transitions; `camera_ground_clamp_system(cameras, follows, transforms)` clamps to ground
- **Fixed physics** (1/60s accumulator):
- `state_machine_physics_system(&mut World, FIXED_TIMESTEP)`: tick state's `on_physics_update()`
- `PhysicsManager::physics_step()`: rapier integration
- `physics_sync_system(entities, physics, transforms)`: copy rigidbody poses to `Transform`
- `trigger_system(trigger_events, triggers, transforms, player_tags)`: detect collisions, emit events
- `dialog_system(entities, trigger_events, dialog_sources, bubble_tags, dialog_bubbles, transforms, names, player_tags, projectile_tags, dialog_projectiles, dialog_outcomes, delta)` ticks story state; `dialog_projectile_system(player_tags, transforms, projectile_tags, dialog_projectiles, spawn_particle_intents, dialog_outcomes, leaping_states, rolling_states, input_state)` moves projectiles
- **Per-frame systems**: `state_machine_system(&mut World, delta)` ticks state lifecycle; `rotate_system(rotates, transforms, delta)` rotates entities; `particle_intent_system(particle_buffers, spawn_particle_intents)`/`particle_update_system(particle_buffers, delta)` manage particles; `tree_occlusion_system(player_tags, transforms, cameras, tree_instances)` culls; `tree_dissolve_update_system(tree_instances, delta)`/`tree_instance_buffer_update_system(tree_instances)` updates dissolve; `snow_system(cameras, transforms, player_tags, follows, snow_layer)` deforms snow; `spotlight_sync_system(spotlights, transforms)` syncs lights
- **Render collection**: `render_system(entities, transforms, meshes, dissolves)``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()` resets one-frame flags
## Key Types
| Type | Module | Description |
|------|--------|-------------|
| `EntityHandle` | entity | u64 opaque entity ID |
| `Storage<T>` | world | HashMap storage for per-entity component data; exposes `.get()`, `.get_mut()`, `.with_mut()`, `.all()` methods |
| `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 (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 take explicit storage parameters from `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

@@ -10,6 +10,25 @@ Pure Rust game: SDL3 windowing, wgpu rendering, rapier3d physics, low-res retro
- **NO inline paths** — always add `use` statements at the top of files, never inline
- **NO `use` statements inside functions or impl blocks** — all `use` must be at the file (module) level
**Intent-Based Architecture:**
- Systems don't call each other. Cross-system communication goes through **intents** — one-frame typed structs in `Storage<T>` queues on `World`
- Producer inserts intent → consumer reads, acts, removes. Producer doesn't know which system processes it
- The main loop is a flat pipeline; systems self-gate based on data presence
- See `docs/self-gating-systems.md` for full pattern + examples
**Storage Parameters:**
- Functions should take specific storages they need rather than `&World` or `&mut World`
- Pass individual fields (`&world.transforms`, `&mut world.state_machines`) at the call site
- This makes data dependencies explicit for both the borrow checker and the reader
## Architecture
Pure ECS: entities are IDs, components are plain data in `HashMap<EntityHandle, T>` storages, systems are functions receiving `&mut World`. No `Rc<RefCell<>>`.
## Sub-Agents & Codebase Exploration
**Use the `explorer` sub-agent for all codebase work.** Unless the target is trivially obvious (e.g., you already know the exact file path and line number) and unless you are the explorer agent. This includes:
- Understanding existing code before making changes
- Searching for related functions/types
- Investigating bugs or architectural patterns
- Finding usages of a function across the codebase

View File

@@ -23,6 +23,8 @@ nalgebra = { version = "0.34.1", features = ["convert-glam030"] }
serde_json = "1.0"
bladeink = "1.2"
wesl = "0.2"
ab_glyph = "0.2"
rand = "0.9"
[build-dependencies]
wesl = "0.2"

Binary file not shown.

View File

@@ -144,9 +144,9 @@
{
"name":"TestCharSpawn",
"translation":[
-381.1509704589844,
106.53739166259766,
107.46959686279297
-376.7967224121094,
115.31475830078125,
155.05471801757812
]
},
{

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()

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,135 @@
# Intent-Based Architecture
Systems communicate through **intents** — one-frame typed data in shared queues — not through direct function calls. This decouples producers from consumers: the system that wants something to happen does not know or care which system processes it.
## The Pattern
```
Producer World (shared state) Consumer
──────── ──────────────────── ────────
detects dialog change
insert(camera, CameraTransitionIntent)
camera_transition_intents
camera_intent_system reads it
sets up CameraTransition component
removes intent
```
An intent is ephemeral (one frame). The state change it triggers (a component mutation) persists.
## Intent Types
Intents are plain structs stored in `Storage<T>` on `World`, keyed by target entity:
```rust
// components/intent.rs
pub struct FollowPlayerIntent;
pub struct StopFollowingIntent;
pub struct CameraTransitionIntent { pub duration: f32 }
```
```rust
// world.rs
pub follow_player_intents: Storage<FollowPlayerIntent>,
pub stop_following_intents: Storage<StopFollowingIntent>,
pub camera_transition_intents: Storage<CameraTransitionIntent>,
```
Adding a new intent = one struct + one storage field. Nothing else changes.
## Producing Intents
Any code with access to the storage can submit. The entity is the target:
```rust
// Event handler wants camera to follow player — doesn't call camera functions
world.follow_player_intents.insert(camera_entity, FollowPlayerIntent);
// Dialog system detects state change — doesn't know how transitions work
world.camera_transition_intents.insert(camera_entity, CameraTransitionIntent { duration: 0.8 });
```
Producers don't import consumer modules. They only know about the intent type and the storage.
## Consuming Intents
A consumer system reads, acts, and removes:
```rust
pub fn camera_intent_system(world: &mut World) {
// 1. Read
let follow_entities = world.follow_player_intents.all();
for entity in follow_entities {
// 2. Act — all the follow setup logic lives here
start_camera_following(world, entity);
// 3. Remove (consume)
world.follow_player_intents.remove(entity);
}
// ... same for stop_following_intents, camera_transition_intents
}
```
The consumer owns the implementation. `start_camera_following` is a private function inside the camera module — no other module can call it directly.
## Why This Matters
**Decoupling.** The dialog system doesn't import camera functions. It inserts a `CameraTransitionIntent`. If the camera system changes how transitions work, the dialog system is unaffected.
**Multiple producers, same path.** Editor toggle, dialog state changes, and future cutscene systems all produce the same `CameraTransitionIntent`. They all go through the same processing. No special cases.
**Testability.** To test camera transitions: insert a `CameraTransitionIntent`, call `camera_intent_system`, check the result. No need to simulate dialog state or editor toggles.
**Additive behavior.** To add a new reaction to `StopFollowingIntent` (e.g., play a sound), write a new system that reads the intent before the camera system consumes it. No existing code changes.
## Execution Order
The main loop is a flat pipeline. Order encodes causality:
```
Input + intent generation (camera_input, player_input, dialog_transition_detect)
Intent processing (camera_intent_system)
Camera behavior (noclip, dialog_camera, follow, transition, ground_clamp)
Editor overlay (UI only — not a game system)
Fixed-step physics (state_machine, physics, triggers, dialog)
Per-frame systems (state_machine, rotate, trees, spotlights, snow)
Render
```
Moving a system changes the data flow. The order is the contract.
## Systems Also Self-Gate
Because intents express what should happen, systems naturally have nothing to do when their data is absent:
- `camera_follow_system` — no `FollowComponent` = no work
- `dialog_camera_system` — no active bubbles = no work
- `player_input_system` — camera not following = no player input
- `camera_noclip_system` — camera has `FollowComponent` = skip
The main loop doesn't branch on mode flags. Systems check their own data.
## When to Use Intents vs. Direct Mutation
| Situation | Approach |
|---|---|
| One system wants another to do something | Intent |
| A system updating its own components | Direct mutation |
| Per-frame continuous computation | Components + tick system |
| Persistent state (is the camera following?) | Component (`FollowComponent`) |
| One-shot request (start following) | Intent (`FollowPlayerIntent`) |
## Adding New Intents
1. Define the struct in `components/intent.rs`
2. Add a `Storage<T>` field to `World` (+ `new()` + `despawn()`)
3. Producers insert into the storage
4. A consumer system reads, acts, and removes
5. Done — no other code changes

View File

@@ -18,8 +18,8 @@ use crate::loaders::mesh::Mesh;
use crate::paths;
use crate::physics::PhysicsManager;
use crate::render::Pipeline;
use crate::state::StateMachine;
use crate::systems::player_states::{LEAP_DURATION, ROLL_DURATION};
use crate::states::player_states::{LEAP_DURATION, ROLL_DURATION};
use crate::states::state::StateMachine;
use crate::world::{Transform, World};
pub struct PlayerBundle

View File

@@ -14,7 +14,7 @@ use crate::loaders::mesh::Mesh;
use crate::paths;
use crate::physics::PhysicsManager;
use crate::render::Pipeline;
use crate::state::StateMachine;
use crate::states::state::StateMachine;
use crate::world::{Transform, World};
pub struct TestCharBundle

View File

@@ -1,4 +1,13 @@
use glam::Mat4;
use glam::{Mat4, Vec3};
pub struct CameraTransition
{
pub source_position: Vec3,
pub source_yaw: f32,
pub source_pitch: f32,
pub elapsed: f32,
pub duration: f32,
}
#[derive(Clone, Copy)]
pub struct CameraComponent

View File

@@ -1,4 +1,5 @@
use bladeink::story::Story;
use glam::Vec3;
use crate::entity::EntityHandle;
@@ -56,6 +57,7 @@ pub struct DialogProjectileComponent
pub bubble_entity: EntityHandle,
pub correct_parry: ParryButton,
pub parry_window_open: bool,
pub velocity: Vec3,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]

8
src/components/intent.rs Normal file
View File

@@ -0,0 +1,8 @@
pub struct FollowPlayerIntent;
pub struct StopFollowingIntent;
pub struct CameraTransitionIntent
{
pub duration: f32,
}

View File

@@ -3,18 +3,20 @@ pub mod dialog;
pub mod dissolve;
pub mod follow;
pub mod input;
pub mod intent;
pub mod jump;
pub mod lights;
pub mod mesh;
pub mod movement;
pub mod noclip;
pub mod particle;
pub mod physics;
pub mod player_states;
pub mod rotate;
pub mod tree_instances;
pub mod trigger;
pub use camera::CameraComponent;
pub use camera::{CameraComponent, CameraTransition};
pub use dialog::{
DialogBubbleComponent, DialogOutcome, DialogOutcomeEvent, DialogPhase,
DialogProjectileComponent, DialogSourceComponent, ParryButton,
@@ -25,6 +27,7 @@ pub use input::InputComponent;
pub use jump::JumpComponent;
pub use mesh::MeshComponent;
pub use movement::MovementComponent;
pub use particle::{ParticleEmitterConfig, SpawnParticleIntent};
pub use physics::PhysicsComponent;
pub use rotate::RotateComponent;
pub use tree_instances::TreeInstancesComponent;

View File

@@ -0,0 +1,18 @@
pub struct ParticleEmitterConfig
{
pub burst_count: u32,
pub lifetime: std::ops::Range<f32>,
pub speed: std::ops::Range<f32>,
pub direction: Option<glam::Vec3>,
pub direction_spread: f32,
pub gravity: f32,
pub size: std::ops::Range<f32>,
pub color_start: [f32; 4],
pub color_end: [f32; 4],
}
pub struct SpawnParticleIntent
{
pub origin: glam::Vec3,
pub config: ParticleEmitterConfig,
}

178
src/debug/gizmo.rs Normal file
View File

@@ -0,0 +1,178 @@
use bytemuck::cast_slice;
use glam::{Mat4, Vec3};
use wgpu::util::DeviceExt;
use crate::entity::EntityHandle;
use crate::loaders::mesh::{InstanceRaw, Mesh, Vertex};
use crate::render::{self, DrawCall, Pipeline};
use crate::utility::transform::Transform;
use crate::world::Storage;
const GIZMO_DISTANCE_SCALE: f32 = 0.1;
const ARROW_BASE: f32 = 0.85;
const ARROW_SPREAD: f32 = 0.08;
pub fn create_gizmo_mesh(device: &wgpu::Device) -> Mesh
{
let vertices = vec![
Vertex {
position: [0.0, 0.0, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [1.0, 0.0, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 0.0, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 1.0, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, 0.0, 1.0],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, ARROW_SPREAD, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, -ARROW_SPREAD, 0.0],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, 0.0, ARROW_SPREAD],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_BASE, 0.0, -ARROW_SPREAD],
normal: [1.0, 0.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_SPREAD, ARROW_BASE, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [-ARROW_SPREAD, ARROW_BASE, 0.0],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, ARROW_BASE, ARROW_SPREAD],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, ARROW_BASE, -ARROW_SPREAD],
normal: [0.0, 1.0, 0.0],
uv: [0.0, 0.0],
},
Vertex {
position: [ARROW_SPREAD, 0.0, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [-ARROW_SPREAD, 0.0, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, ARROW_SPREAD, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
Vertex {
position: [0.0, -ARROW_SPREAD, ARROW_BASE],
normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
},
];
let indices = vec![
0, 1, 2, 3, 4, 5, 6, 1, 7, 1, 8, 1, 9, 1, 10, 3, 11, 3, 12, 3, 13, 3, 14, 5, 15, 5, 16, 5,
17, 5,
];
Mesh::new(device, &vertices, &indices)
}
pub fn create_gizmo_instance_buffer(device: &wgpu::Device) -> wgpu::Buffer
{
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Gizmo Instance Buffer"),
contents: cast_slice(&[InstanceRaw {
model: Mat4::IDENTITY.to_cols_array_2d(),
dissolve_amount: 0.0,
_padding: [0.0; 3],
}]),
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
})
}
pub fn render_transform_gizmo(
entity: EntityHandle,
transforms: &Storage<Transform>,
camera_position: Vec3,
gizmo_mesh: &Mesh,
instance_buffer: &wgpu::Buffer,
) -> Vec<DrawCall>
{
let transform = match transforms.get(entity)
{
Some(t) => t,
None => return Vec::new(),
};
let distance = camera_position.distance(transform.position);
let scale = (distance * GIZMO_DISTANCE_SCALE).max(0.1);
let model = Mat4::from_scale_rotation_translation(
Vec3::splat(scale),
transform.rotation,
transform.position,
);
let instance_data = InstanceRaw {
model: model.to_cols_array_2d(),
dissolve_amount: 0.0,
_padding: [0.0; 3],
};
render::with_queue(|queue| {
queue.write_buffer(instance_buffer, 0, cast_slice(&[instance_data]));
});
vec![DrawCall {
vertex_buffer: gizmo_mesh.vertex_buffer.clone(),
index_buffer: gizmo_mesh.index_buffer.clone(),
num_indices: gizmo_mesh.num_indices,
model,
pipeline: Pipeline::GizmoLines,
instance_buffer: Some(instance_buffer.clone()),
num_instances: 1,
tile_scale: 4.0,
enable_dissolve: false,
enable_snow_light: false,
displacement_bind_group: None,
entity: None,
}]
}

View File

@@ -1,4 +1,5 @@
pub mod collider_debug;
pub mod gizmo;
pub mod mode;
pub use mode::DebugMode;

View File

@@ -3,8 +3,6 @@ mod inspector;
use sdl3_sys::events::SDL_Event;
use crate::entity::EntityHandle;
use crate::systems::camera_noclip_system;
use crate::utility::input::InputState;
use crate::world::World;
pub use inspector::FrameStats;
@@ -73,18 +71,8 @@ impl EditorState
}
}
pub fn editor_loop(
editor: &mut EditorState,
world: &mut World,
input_state: &InputState,
stats: &FrameStats,
delta: f32,
)
pub fn editor_loop(editor: &mut EditorState, world: &mut World, stats: &FrameStats)
{
if editor.right_mouse_held
{
camera_noclip_system(world, input_state, delta);
}
let selected = editor.selected_entity;
let show_player_state = editor.show_player_state;
editor

View File

@@ -9,16 +9,38 @@ mod physics;
mod picking;
mod postprocess;
mod render;
mod snow;
mod snow_light;
mod state;
mod states;
mod systems;
mod texture;
mod utility;
mod world;
use crate::debug::{collider_debug, DebugMode};
use crate::bundles::camera::CameraBundle;
use crate::bundles::player::PlayerBundle;
use crate::bundles::spotlight::spawn_spotlights;
use crate::bundles::terrain::{TerrainBundle, TerrainConfig};
use crate::bundles::test_char::TestCharBundle;
use crate::bundles::Bundle;
use crate::components::intent::{FollowPlayerIntent, StopFollowingIntent};
use crate::debug::{collider_debug, gizmo, DebugMode};
use crate::editor::{editor_loop, EditorState, FrameStats};
use crate::entity::EntityHandle;
use crate::loaders::scene::Space;
use crate::physics::PhysicsManager;
use crate::render::particle_types::ParticleInstanceRaw;
use crate::render::snow::{SnowConfig, SnowLayer};
use crate::systems::{
camera_follow_system, camera_ground_clamp_system, camera_input_system, camera_intent_system,
camera_noclip_system, camera_transition_system, camera_view_matrix, collect_instances,
dialog_bubble_render_system, dialog_camera_system, dialog_camera_transition_system,
dialog_projectile_system, dialog_system, particle_intent_system, particle_update_system,
physics_sync_system, player_input_system, render_system, rotate_system, snow_system,
spawn_snow_particles, spotlight_sync_system, state_machine_physics_system,
state_machine_system, tree_dissolve_update_system, tree_instance_buffer_update_system,
tree_occlusion_system, trigger_system,
};
use crate::utility::input::InputState;
use crate::utility::time::Time;
use std::time::{Duration, Instant};
@@ -27,31 +49,24 @@ use render::Renderer;
use sdl3::event::Event;
use sdl3::keyboard::Keycode;
use sdl3::mouse::MouseButton;
use utility::input::InputState;
use world::World;
use crate::bundles::camera::CameraBundle;
use crate::bundles::player::PlayerBundle;
use crate::bundles::spotlight::spawn_spotlights;
use crate::bundles::terrain::{TerrainBundle, TerrainConfig};
use crate::bundles::test_char::TestCharBundle;
use crate::bundles::Bundle;
use crate::loaders::scene::Space;
use crate::physics::PhysicsManager;
use crate::snow::{SnowConfig, SnowLayer};
struct Game
{
sdl_context: sdl3::Sdl,
window: sdl3::video::Window,
_event_pump: sdl3::EventPump,
world: World,
editor: EditorState,
input_state: InputState,
camera_entity: EntityHandle,
last_frame: Instant,
frame_duration: Duration,
physics_accumulator: f32,
stats: FrameStats,
}
use crate::systems::camera::stop_camera_following;
use crate::systems::{
camera_follow_system, camera_input_system, camera_view_matrix, dialog_bubble_render_system,
dialog_camera_system, dialog_projectile_system, dialog_system, physics_sync_system,
player_input_system, render_system, rotate_system, snow_system, spotlight_sync_system,
start_camera_following, state_machine_physics_system, state_machine_system,
tree_dissolve_update_system, tree_instance_buffer_update_system, tree_occlusion_system,
trigger_system,
};
use crate::utility::time::Time;
fn main() -> Result<(), Box<dyn std::error::Error>>
fn init() -> Result<Game, Box<dyn std::error::Error>>
{
let sdl_context = sdl3::init()?;
let video_subsystem = sdl_context.video()?;
@@ -60,6 +75,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
.window("snow_trail", 1200, 900)
.position_centered()
.resizable()
.high_pixel_density()
.vulkan()
.build()?;
let renderer = pollster::block_on(Renderer::new(&window, 2))?;
@@ -72,34 +88,71 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
});
editor.init_platform(&window);
let (mut world, camera_entity) = init_world()?;
world
.follow_player_intents
.insert(camera_entity, FollowPlayerIntent);
let _event_pump = sdl_context.event_pump()?;
let input_state = InputState::new();
sdl_context.mouse().set_relative_mouse_mode(&window, true);
Time::init();
Ok(Game {
sdl_context,
window,
_event_pump,
world,
editor,
input_state,
camera_entity,
last_frame: Instant::now(),
frame_duration: Duration::from_millis(1000 / 60),
physics_accumulator: 0.0,
stats: FrameStats {
fps: 0.0,
frame_ms: 0.0,
physics_budget_ms: 0.0,
draw_call_count: 0,
},
})
}
fn init_world() -> Result<(World, EntityHandle), Box<dyn std::error::Error>>
{
let space = Space::load_space(&crate::paths::meshes::terrain())?;
let terrain_config = TerrainConfig::default();
let player_spawn = space.player_spawn;
let camera_spawn = space.camera_spawn_position();
let tree_positions: Vec<Vec3> = space
.mesh_data
.iter()
.flat_map(|(_, instances)| instances.iter().map(|inst| inst.position))
.collect();
let player_spawn = space.player_spawn;
let test_char_spawn = space.test_char_spawn;
let camera_spawn = space.camera_spawn_position();
let spotlights = space.spotlights;
let mesh_data = space.mesh_data;
let mut world = World::new();
let _player_entity = PlayerBundle {
PlayerBundle {
position: player_spawn,
}
.spawn(&mut world)
.unwrap();
let _test_char_entity = TestCharBundle {
position: space.test_char_spawn,
TestCharBundle {
position: test_char_spawn,
}
.spawn(&mut world)
.unwrap();
let _terrain_entity = TerrainBundle::spawn(&mut world, space.mesh_data, &terrain_config)?;
spawn_spotlights(&mut world, space.spotlights);
TerrainBundle::spawn(&mut world, mesh_data, &terrain_config)?;
spawn_spotlights(&mut world, spotlights);
render::set_terrain_data();
@@ -110,294 +163,483 @@ fn main() -> Result<(), Box<dyn std::error::Error>>
);
let snow_config = SnowConfig::default();
let mut snow_layer = SnowLayer::load(&snow_config)?;
let snow_layer = SnowLayer::load(&snow_config)?;
for pos in &tree_positions
{
snow_layer.deform_at_position(*pos, 5.0, 50.0);
}
println!("Snow layer loaded successfully");
render::set_snow_depth(&snow_layer.depth_texture_view);
let mut debug_mode = DebugMode::default();
world.snow_layer = Some(snow_layer);
let camera_entity = CameraBundle {
position: camera_spawn,
}
.spawn(&mut world)
.unwrap();
start_camera_following(&mut world, camera_entity);
let _event_pump = sdl_context.event_pump()?;
let mut input_state = InputState::new();
Ok((world, camera_entity))
}
sdl_context.mouse().set_relative_mouse_mode(&window, true);
fn process_events(game: &mut Game) -> bool
{
game.editor.begin_frame();
Time::init();
let mut last_frame = Instant::now();
let target_fps = 60;
let frame_duration = Duration::from_millis(1000 / target_fps);
const FIXED_TIMESTEP: f32 = 1.0 / 60.0;
let mut physics_accumulator = 0.0;
let mut stats = FrameStats {
fps: 0.0,
frame_ms: 0.0,
physics_budget_ms: 0.0,
draw_call_count: 0,
};
'running: loop
while let Some(raw_event) = dear_imgui_sdl3::sdl3_poll_event_ll()
{
let frame_start = Instant::now();
let time = Time::get_time_elapsed();
let delta = (frame_start - last_frame).as_secs_f32();
last_frame = frame_start;
game.editor.process_event(&raw_event);
let event = Event::from_ll(raw_event);
editor.begin_frame();
while let Some(raw_event) = dear_imgui_sdl3::sdl3_poll_event_ll()
match &event
{
editor.process_event(&raw_event);
let event = Event::from_ll(raw_event);
match &event
Event::Quit { .. } =>
{
Event::Quit { .. } =>
{
input_state.quit_requested = true;
continue;
}
Event::KeyDown {
keycode: Some(Keycode::Tab),
repeat: false,
..
} =>
{
editor.active = !editor.active;
if editor.active
{
stop_camera_following(&mut world, camera_entity);
sdl_context.mouse().set_relative_mouse_mode(&window, false);
editor.right_mouse_held = false;
input_state.mouse_captured = false;
}
else
{
start_camera_following(&mut world, camera_entity);
input_state.mouse_captured = true;
sdl_context.mouse().set_relative_mouse_mode(&window, true);
}
continue;
}
Event::MouseButtonDown {
mouse_btn: MouseButton::Right,
..
} if editor.active =>
{
editor.right_mouse_held = true;
input_state.mouse_captured = true;
stop_camera_following(&mut world, camera_entity);
sdl_context.mouse().set_relative_mouse_mode(&window, true);
continue;
}
Event::MouseButtonUp {
mouse_btn: MouseButton::Right,
..
} if editor.active =>
{
editor.right_mouse_held = false;
input_state.mouse_captured = false;
sdl_context.mouse().set_relative_mouse_mode(&window, false);
continue;
}
Event::MouseButtonDown {
mouse_btn: MouseButton::Left,
x,
y,
..
} if editor.active && !editor.wants_mouse() =>
{
if let Some(view) = crate::systems::camera_view_matrix(&world)
{
if let Some((_, cam)) = world.active_camera()
{
let projection = cam.projection_matrix();
let (win_w, win_h) = window.size();
let ray = crate::picking::Ray::from_screen_position(
*x,
*y,
win_w,
win_h,
&view,
&projection,
);
editor.selected_entity = crate::picking::pick_entity(&ray, &world);
render::set_selected_entity(editor.selected_entity);
}
}
continue;
}
_ =>
{}
return true;
}
if editor.active && (editor.wants_keyboard() || editor.wants_mouse())
Event::KeyDown {
keycode: Some(Keycode::Tab),
repeat: false,
..
} =>
{
toggle_editor(game);
continue;
}
let capture_changed = input_state.handle_event(&event);
if capture_changed && !editor.active
Event::MouseButtonDown {
mouse_btn: MouseButton::Right,
..
} if game.editor.active =>
{
sdl_context
game.editor.right_mouse_held = true;
game.input_state.mouse_captured = true;
game.world
.stop_following_intents
.insert(game.camera_entity, StopFollowingIntent);
game.sdl_context
.mouse()
.set_relative_mouse_mode(&window, input_state.mouse_captured);
.set_relative_mouse_mode(&game.window, true);
continue;
}
}
if input_state.quit_requested
{
break 'running;
}
if input_state.debug_cycle_just_pressed
{
debug_mode = debug_mode.cycle();
println!("Debug mode: {:?}", debug_mode);
}
if input_state.f2_just_pressed
{
editor.show_player_state = !editor.show_player_state;
}
camera_input_system(&mut world, &input_state);
if editor.active
{
editor_loop(&mut editor, &mut world, &input_state, &stats, delta);
}
else
{
let dialog_active = !world.bubble_tags.all().is_empty();
if dialog_active
Event::MouseButtonUp {
mouse_btn: MouseButton::Right,
..
} if game.editor.active =>
{
dialog_camera_system(&mut world, delta);
game.editor.right_mouse_held = false;
game.input_state.mouse_captured = false;
game.sdl_context
.mouse()
.set_relative_mouse_mode(&game.window, false);
continue;
}
else
Event::MouseButtonDown {
mouse_btn: MouseButton::Left,
x,
y,
..
} if game.editor.active && !game.editor.wants_mouse() =>
{
camera_follow_system(&mut world);
}
player_input_system(&mut world, &input_state);
if editor.show_player_state
{
editor.build_hud(&world);
handle_editor_pick(game, *x, *y);
continue;
}
_ =>
{}
}
if game.editor.active && (game.editor.wants_keyboard() || game.editor.wants_mouse())
{
continue;
}
let capture_changed = game.input_state.handle_event(&event);
if capture_changed && !game.editor.active
{
game.sdl_context
.mouse()
.set_relative_mouse_mode(&game.window, game.input_state.mouse_captured);
}
}
false
}
fn toggle_editor(game: &mut Game)
{
game.editor.active = !game.editor.active;
if game.editor.active
{
game.world
.stop_following_intents
.insert(game.camera_entity, StopFollowingIntent);
game.sdl_context
.mouse()
.set_relative_mouse_mode(&game.window, false);
game.editor.right_mouse_held = false;
game.input_state.mouse_captured = false;
if game.world.gizmo_mesh.is_none()
{
game.world.gizmo_mesh = Some(render::with_device(|device| {
gizmo::create_gizmo_mesh(device)
}));
game.world.gizmo_instance_buffer = Some(render::with_device(|device| {
gizmo::create_gizmo_instance_buffer(device)
}));
}
}
else
{
game.world
.follow_player_intents
.insert(game.camera_entity, FollowPlayerIntent);
game.input_state.mouse_captured = true;
game.sdl_context
.mouse()
.set_relative_mouse_mode(&game.window, true);
}
}
fn handle_editor_pick(game: &mut Game, x: f32, y: f32)
{
let view = match camera_view_matrix(
&game.world.cameras,
&game.world.transforms,
&game.world.follows,
)
{
Some(v) => v,
None => return,
};
let (_, cam) = match game.world.active_camera()
{
Some(c) => c,
None => return,
};
let projection = cam.projection_matrix();
let (win_w, win_h) = game.window.size();
let ray = picking::Ray::from_screen_position(x, y, win_w, win_h, &view, &projection);
game.editor.selected_entity = picking::pick_entity(&ray, &game.world);
render::set_selected_entity(game.editor.selected_entity);
}
fn submit_frame(
game: &mut Game,
draw_calls: &[render::DrawCall],
particle_instances: &[ParticleInstanceRaw],
time: f32,
delta: f32,
)
{
let (camera_entity, camera_component) = match game.world.active_camera()
{
Some(c) => c,
None => return,
};
let camera_transform = match game.world.transforms.get(camera_entity)
{
Some(t) => t,
None => return,
};
let view = match camera_view_matrix(
&game.world.cameras,
&game.world.transforms,
&game.world.follows,
)
{
Some(v) => v,
None => return,
};
let projection = camera_component.projection_matrix();
let view_proj = projection * view;
let player_pos = game.world.player_position();
let (billboard_calls, text_vertices) = dialog_bubble_render_system(
&game.world.transforms,
&game.world.dialog_bubbles,
&game.world.bubble_tags,
camera_transform.position,
view_proj,
);
let frame = render::render(
&view,
&projection,
camera_transform.position,
player_pos,
draw_calls,
&billboard_calls,
&text_vertices,
particle_instances,
time,
delta,
game.world.debug_mode,
);
if game.editor.active || game.editor.show_player_state
{
let screen_view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = render::with_device(|d| {
d.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("ImGui Encoder"),
})
});
game.editor.render(&mut encoder, &screen_view);
render::with_queue(|q| q.submit(std::iter::once(encoder.finish())));
}
frame.present();
}
const FIXED_TIMESTEP: f32 = 1.0 / 60.0;
fn main() -> Result<(), Box<dyn std::error::Error>>
{
let mut game = init()?;
loop
{
let frame_start = Instant::now();
let time = Time::get_time_elapsed();
let delta = (frame_start - game.last_frame).as_secs_f32();
game.last_frame = frame_start;
// --- events ---
if process_events(&mut game)
{
break;
}
if game.input_state.debug_cycle_just_pressed
{
game.world.debug_mode = game.world.debug_mode.cycle();
println!("Debug mode: {:?}", game.world.debug_mode);
}
if game.input_state.f2_just_pressed
{
game.editor.show_player_state = !game.editor.show_player_state;
}
// --- intent generation ---
camera_input_system(
&mut game.world.cameras,
&game.world.follows,
&game.input_state,
);
player_input_system(
&game.world.cameras,
&game.world.follows,
&game.world.player_tags,
&mut game.world.inputs,
&game.input_state,
);
dialog_camera_transition_system(
&game.world.bubble_tags,
&mut game.world.camera_transition_intents,
&mut game.world.was_dialog_active,
game.camera_entity,
);
// --- intent processing + camera ---
camera_intent_system(
&mut game.world.follow_player_intents,
&mut game.world.stop_following_intents,
&mut game.world.camera_transition_intents,
&mut game.world.follows,
&mut game.world.transforms,
&mut game.world.cameras,
&game.world.player_tags,
&mut game.world.camera_transitions,
);
camera_noclip_system(
&game.world.cameras,
&game.world.follows,
&mut game.world.transforms,
&game.input_state,
delta,
);
let player_pos = game.world.player_position();
dialog_camera_system(
&mut game.world.cameras,
&mut game.world.transforms,
&game.world.bubble_tags,
player_pos,
delta,
);
camera_follow_system(
&mut game.world.follows,
&game.world.cameras,
&mut game.world.transforms,
);
camera_transition_system(
&mut game.world.camera_transitions,
&mut game.world.transforms,
&mut game.world.cameras,
delta,
);
camera_ground_clamp_system(
&game.world.cameras,
&game.world.follows,
&mut game.world.transforms,
);
// --- editor overlay ---
if game.editor.active
{
editor_loop(&mut game.editor, &mut game.world, &game.stats);
}
if game.editor.show_player_state
{
game.editor.build_hud(&game.world);
}
// --- fixed-step physics ---
let physics_start = Instant::now();
game.physics_accumulator += delta;
physics_accumulator += delta;
while physics_accumulator >= FIXED_TIMESTEP
while game.physics_accumulator >= FIXED_TIMESTEP
{
state_machine_physics_system(&mut world, FIXED_TIMESTEP);
state_machine_physics_system(&mut game.world, FIXED_TIMESTEP);
PhysicsManager::physics_step();
physics_sync_system(&mut world);
trigger_system(&mut world);
dialog_system(&mut world, FIXED_TIMESTEP);
dialog_projectile_system(&mut world, &input_state);
physics_accumulator -= FIXED_TIMESTEP;
physics_sync_system(
&game.world.entities,
&game.world.physics,
&mut game.world.transforms,
);
trigger_system(
&mut game.world.trigger_events,
&mut game.world.triggers,
&game.world.transforms,
&game.world.player_tags,
);
dialog_system(
&mut game.world.entities,
&game.world.trigger_events,
&game.world.dialog_sources,
&mut game.world.bubble_tags,
&mut game.world.dialog_bubbles,
&mut game.world.transforms,
&mut game.world.names,
&game.world.player_tags,
&mut game.world.projectile_tags,
&mut game.world.dialog_projectiles,
&mut game.world.dialog_outcomes,
FIXED_TIMESTEP,
);
dialog_projectile_system(
&game.world.player_tags,
&mut game.world.transforms,
&mut game.world.projectile_tags,
&mut game.world.dialog_projectiles,
&mut game.world.spawn_particle_intents,
&mut game.world.dialog_outcomes,
&game.world.leaping_states,
&game.world.rolling_states,
&game.input_state,
);
game.physics_accumulator -= FIXED_TIMESTEP;
}
stats.physics_budget_ms = physics_start.elapsed().as_secs_f32() * 1000.0;
game.stats.physics_budget_ms = physics_start.elapsed().as_secs_f32() * 1000.0;
state_machine_system(&mut world, delta);
// --- per-frame systems ---
state_machine_system(&mut game.world, delta);
rotate_system(&game.world.rotates, &mut game.world.transforms, delta);
rotate_system(&mut world, delta);
tree_occlusion_system(
&game.world.player_tags,
&game.world.transforms,
&game.world.cameras,
&mut game.world.tree_instances,
);
tree_dissolve_update_system(&mut game.world.tree_instances, delta);
tree_instance_buffer_update_system(&game.world.tree_instances);
tree_occlusion_system(&mut world);
tree_dissolve_update_system(&mut world, delta);
tree_instance_buffer_update_system(&mut world);
let spotlights = spotlight_sync_system(&world);
let spotlights = spotlight_sync_system(&game.world.spotlights, &game.world.transforms);
render::update_spotlights(spotlights);
snow_system(&world, &mut snow_layer, editor.active);
snow_system(
&game.world.cameras,
&game.world.transforms,
&game.world.player_tags,
&game.world.follows,
&mut game.world.snow_layer,
);
let mut draw_calls = render_system(&world);
draw_calls.extend(snow_layer.get_draw_calls());
particle_intent_system(
&mut game.world.particle_buffers,
&mut game.world.spawn_particle_intents,
);
particle_update_system(&mut game.world.particle_buffers, delta);
let particle_cam_pos = game.world.active_camera_position();
if let Some(ref mut buffers) = game.world.particle_buffers
{
spawn_snow_particles(buffers, particle_cam_pos, delta);
}
if debug_mode == DebugMode::Colliders
// --- draw call collection ---
let mut draw_calls = render_system(
&game.world.entities,
&game.world.transforms,
&game.world.meshes,
&game.world.dissolves,
);
if let Some(ref snow_layer) = game.world.snow_layer
{
draw_calls.extend(snow_layer.get_draw_calls());
}
if game.world.debug_mode == DebugMode::Colliders
{
draw_calls.extend(collider_debug::render_collider_debug());
}
if let Some((camera_entity, camera_component)) = world.active_camera()
if game.editor.active
{
if let Some(camera_transform) = world.transforms.get(camera_entity)
if let Some(entity) = game.editor.selected_entity
{
let player_pos = world.player_position();
if let Some(view) = camera_view_matrix(&world)
let cam_pos = game.world.active_camera_position();
if let (Some(ref mesh), Some(ref buf)) =
(&game.world.gizmo_mesh, &game.world.gizmo_instance_buffer)
{
let projection = camera_component.projection_matrix();
let view_proj = projection * view;
let billboard_calls =
dialog_bubble_render_system(&world, camera_transform.position, view_proj);
stats.draw_call_count = draw_calls.len();
stats.fps = 1.0 / delta;
stats.frame_ms = delta * 1000.0;
let frame = render::render(
&view,
&projection,
camera_transform.position,
player_pos,
&draw_calls,
&billboard_calls,
time,
delta,
debug_mode,
);
if editor.active || editor.show_player_state
{
let screen_view = frame
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = render::with_device(|d| {
d.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("ImGui Encoder"),
})
});
editor.render(&mut encoder, &screen_view);
render::with_queue(|q| q.submit(std::iter::once(encoder.finish())));
}
frame.present();
draw_calls.extend(gizmo::render_transform_gizmo(
entity,
&game.world.transforms,
cam_pos,
mesh,
buf,
));
}
}
}
input_state.clear_just_pressed();
game.stats.draw_call_count = draw_calls.len();
game.stats.fps = 1.0 / delta;
game.stats.frame_ms = delta * 1000.0;
let particle_instances: Vec<ParticleInstanceRaw> = game
.world
.particle_buffers
.as_mut()
.map(|b| collect_instances(b).to_vec())
.unwrap_or_default();
// --- render ---
submit_frame(&mut game, &draw_calls, &particle_instances, time, delta);
// --- end frame ---
game.input_state.clear_just_pressed();
let frame_time = frame_start.elapsed();
if frame_time < frame_duration
if frame_time < game.frame_duration
{
std::thread::sleep(frame_duration - frame_time);
std::thread::sleep(game.frame_duration - frame_time);
}
}

View File

@@ -63,6 +63,16 @@ pub mod dialogs
}
}
pub mod fonts
{
use crate::paths::ASSETS_DIR;
pub fn departure_mono() -> String
{
format!("{}/fonts/DepartureMono-Regular.otf", ASSETS_DIR)
}
}
pub mod shaders
{
use crate::paths::SHADERS_DIR;
@@ -87,6 +97,16 @@ pub mod shaders
format!("{}/snow_deform.wgsl", SHADERS_DIR)
}
pub fn text() -> String
{
format!("{}/text.wgsl", SHADERS_DIR)
}
pub fn particle() -> String
{
format!("{}/particle.wgsl", SHADERS_DIR)
}
pub const SHADOW_PACKAGE: &str = "package::shadow";
pub const MAIN_PACKAGE: &str = "package::main";
pub const SNOW_LIGHT_ACCUMULATION_PACKAGE: &str = "package::snow_light_accumulation";

291
src/render/font_atlas.rs Normal file
View File

@@ -0,0 +1,291 @@
use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
use glam::Vec3;
use crate::paths;
use super::text_pipeline::TextVertex;
const ATLAS_COLS: u32 = 16;
const ATLAS_ROWS: u32 = 8;
const FIRST_CHAR: u32 = 32;
const LAST_CHAR: u32 = 126;
const NUM_GLYPHS: u32 = LAST_CHAR - FIRST_CHAR + 1;
const FONT_PX: f32 = 124.0;
struct GlyphMetrics
{
uv_min: [f32; 2],
uv_max: [f32; 2],
}
pub struct FontAtlas
{
pub texture_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
glyphs: Vec<GlyphMetrics>,
pub cell_w: f32,
pub cell_h: f32,
}
impl FontAtlas
{
pub fn load(device: &wgpu::Device, queue: &wgpu::Queue) -> Self
{
let path = paths::fonts::departure_mono();
let data = std::fs::read(&path).unwrap_or_else(|_| panic!("Failed to read font: {path}"));
let font = FontVec::try_from_vec(data).expect("Failed to parse font");
let scale = PxScale::from(FONT_PX);
let scaled = font.as_scaled(scale);
let ascent = scaled.ascent();
let descent = scaled.descent();
let cell_h = (ascent - descent).ceil() as u32;
let cell_w = scaled.h_advance(font.glyph_id(' ')).ceil() as u32;
let atlas_w = ATLAS_COLS * cell_w;
let atlas_h = ATLAS_ROWS * cell_h;
let mut pixels = vec![0u8; (atlas_w * atlas_h) as usize];
let mut glyphs = Vec::with_capacity(NUM_GLYPHS as usize);
for idx in 0..NUM_GLYPHS
{
let ch = char::from_u32(FIRST_CHAR + idx).unwrap_or(' ');
let col = idx % ATLAS_COLS;
let row = idx / ATLAS_COLS;
let cell_x = col * cell_w;
let cell_y = row * cell_h;
glyphs.push(GlyphMetrics {
uv_min: [
cell_x as f32 / atlas_w as f32,
cell_y as f32 / atlas_h as f32,
],
uv_max: [
(cell_x + cell_w) as f32 / atlas_w as f32,
(cell_y + cell_h) as f32 / atlas_h as f32,
],
});
let glyph_id = font.glyph_id(ch);
let glyph = glyph_id.with_scale_and_position(
scale,
ab_glyph::point(cell_x as f32, cell_y as f32 + ascent),
);
if let Some(outlined) = font.outline_glyph(glyph)
{
let bounds = outlined.px_bounds();
outlined.draw(|x, y, coverage| {
let abs_x = bounds.min.x.floor() as i32 + x as i32;
let abs_y = bounds.min.y.floor() as i32 + y as i32;
if abs_x >= 0 && abs_y >= 0
{
let ax = abs_x as u32;
let ay = abs_y as u32;
if ax < atlas_w && ay < atlas_h
{
let i = (ay * atlas_w + ax) as usize;
let v = (coverage * 255.0 + 0.5) as u8;
pixels[i] = pixels[i].max(v);
}
}
});
}
}
let extent = wgpu::Extent3d {
width: atlas_w,
height: atlas_h,
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Font Atlas"),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
&pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(atlas_w),
rows_per_image: Some(atlas_h),
},
extent,
);
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Font Atlas Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
texture_view,
sampler,
glyphs,
cell_w: cell_w as f32,
cell_h: cell_h as f32,
}
}
pub fn aspect(&self) -> f32
{
self.cell_w / self.cell_h
}
/// Build billboard-space text quads for a single dialog bubble.
///
/// `anchor` world-space centre of the bubble body
/// `right`/`up` billboard orientation vectors
/// `inner_half_w` half-width of the text area in world units
/// `inner_top_y` y-offset (up-axis) of the top edge of the text area
/// `char_world_h` world-space height of one character cell
/// `line_spacing` extra vertical gap between lines in world units
/// Returns `(n_lines, max_chars_in_any_line)` for the given text wrapped at `inner_half_w`.
pub fn measure_text(&self, text: &str, char_world_h: f32, inner_half_w: f32) -> (usize, usize)
{
let char_w = char_world_h * self.aspect();
let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize;
if chars_per_line == 0
{
return (1, 0);
}
let lines = word_wrap(text, chars_per_line);
let n_lines = lines.len().max(1);
let max_chars = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
(n_lines, max_chars)
}
pub fn build_bubble_text(
&self,
text: &str,
anchor: Vec3,
right: Vec3,
up: Vec3,
inner_half_w: f32,
inner_top_y: f32,
char_world_h: f32,
line_spacing: f32,
) -> Vec<TextVertex>
{
let char_w = char_world_h * self.aspect();
let chars_per_line = ((inner_half_w * 2.0) / char_w).floor() as usize;
if chars_per_line == 0
{
return Vec::new();
}
let lines = word_wrap(text, chars_per_line);
let half_char_h = char_world_h * 0.5;
let half_char_w = char_w * 0.5;
let text_left = -inner_half_w;
let mut verts = Vec::new();
for (row, line) in lines.iter().enumerate()
{
let y = inner_top_y - half_char_h - row as f32 * (char_world_h + line_spacing);
if y < -inner_top_y + half_char_h
{
break;
}
for (col, ch) in line.chars().enumerate()
{
if ch == ' '
{
continue;
}
let code = ch as u32;
if code < FIRST_CHAR || code > LAST_CHAR
{
continue;
}
let g = &self.glyphs[(code - FIRST_CHAR) as usize];
let x = text_left + half_char_w + col as f32 * char_w;
let centre = anchor + right * x + up * y;
let tl = centre - right * half_char_w + up * half_char_h;
let tr = centre + right * half_char_w + up * half_char_h;
let br = centre + right * half_char_w - up * half_char_h;
let bl = centre - right * half_char_w - up * half_char_h;
verts.extend_from_slice(&[
TextVertex {
position: tl.to_array(),
uv: g.uv_min,
},
TextVertex {
position: tr.to_array(),
uv: [g.uv_max[0], g.uv_min[1]],
},
TextVertex {
position: br.to_array(),
uv: g.uv_max,
},
TextVertex {
position: bl.to_array(),
uv: [g.uv_min[0], g.uv_max[1]],
},
]);
}
}
verts
}
}
fn word_wrap(text: &str, chars_per_line: usize) -> Vec<String>
{
let mut lines: Vec<String> = Vec::new();
let mut current = String::new();
for word in text.split_whitespace()
{
if current.is_empty()
{
current.push_str(word);
}
else if current.len() + 1 + word.len() <= chars_per_line
{
current.push(' ');
current.push_str(word);
}
else
{
lines.push(current.clone());
current = word.to_string();
}
}
if !current.is_empty()
{
lines.push(current);
}
lines
}

140
src/render/global.rs Normal file
View File

@@ -0,0 +1,140 @@
/// Global renderer access via thread-local storage.
///
/// This module isolates the global singleton pattern used to give systems
/// and loaders access to the wgpu `Device` and `Queue` without threading
/// them through every call site. The long-term goal is to replace these
/// with explicit `RenderContext` parameters on systems that need GPU access.
use std::cell::RefCell;
use crate::debug::DebugMode;
use crate::entity::EntityHandle;
use super::particle_types::ParticleInstanceRaw;
use super::text_pipeline::TextVertex;
use super::{BillboardDrawCall, DrawCall, FontAtlas, Renderer, Spotlight};
thread_local! {
static GLOBAL_RENDERER: RefCell<Option<Renderer>> = RefCell::new(None);
}
fn with_ref<F, R>(f: F) -> R
where
F: FnOnce(&Renderer) -> R,
{
GLOBAL_RENDERER.with(|r| {
let renderer = r.borrow();
let renderer = renderer.as_ref().expect("Renderer not initialized");
f(renderer)
})
}
fn with_mut<F, R>(f: F) -> R
where
F: FnOnce(&mut Renderer) -> R,
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not initialized");
f(renderer)
})
}
pub fn init(renderer: Renderer)
{
GLOBAL_RENDERER.with(|r| *r.borrow_mut() = Some(renderer));
}
pub fn with_device<F, R>(f: F) -> R
where
F: FnOnce(&wgpu::Device) -> R,
{
with_ref(|r| f(&r.device))
}
pub fn with_queue<F, R>(f: F) -> R
where
F: FnOnce(&wgpu::Queue) -> R,
{
with_ref(|r| f(&r.queue))
}
pub fn with_surface_format<F, R>(f: F) -> R
where
F: FnOnce(wgpu::TextureFormat) -> R,
{
with_ref(|r| f(r.config.format))
}
pub fn aspect_ratio() -> f32
{
with_ref(|r| r.aspect_ratio())
}
pub fn set_terrain_data()
{
with_mut(|r| r.set_terrain_data());
}
pub fn init_snow_light_accumulation(terrain_min: glam::Vec2, terrain_max: glam::Vec2)
{
with_mut(|r| r.init_snow_light_accumulation(terrain_min, terrain_max));
}
pub fn set_snow_depth(snow_depth_view: &wgpu::TextureView)
{
with_mut(|r| r.set_snow_depth(snow_depth_view));
}
#[allow(dead_code)]
pub fn set_shadow_bias(bias: f32)
{
with_mut(|r| r.shadow_bias = bias);
}
pub fn update_spotlights(spotlights: Vec<Spotlight>)
{
with_mut(|r| r.spotlights = spotlights);
}
pub fn set_selected_entity(entity: Option<EntityHandle>)
{
with_mut(|r| r.selected_entity = entity);
}
pub fn with_font_atlas<F, R>(f: F) -> R
where
F: FnOnce(&FontAtlas) -> R,
{
with_ref(|r| f(&r.font_atlas))
}
pub fn render(
view: &glam::Mat4,
projection: &glam::Mat4,
camera_position: glam::Vec3,
player_position: glam::Vec3,
draw_calls: &[DrawCall],
billboard_calls: &[BillboardDrawCall],
text_vertices: &[TextVertex],
particle_instances: &[ParticleInstanceRaw],
time: f32,
delta_time: f32,
debug_mode: DebugMode,
) -> wgpu::SurfaceTexture
{
with_mut(|r| {
r.render(
view,
projection,
camera_position,
player_position,
draw_calls,
billboard_calls,
text_vertices,
particle_instances,
time,
delta_time,
debug_mode,
)
})
}

View File

@@ -1,11 +1,28 @@
pub mod billboard;
mod bind_group;
mod debug_overlay;
mod global;
mod pipeline;
mod shadow;
mod types;
pub mod billboard;
pub mod font_atlas;
pub mod particle_pipeline;
pub mod particle_types;
pub mod snow;
pub mod snow_light;
pub mod text_pipeline;
pub use billboard::{BillboardDrawCall, BillboardPipeline};
pub use font_atlas::FontAtlas;
pub use global::{
aspect_ratio, init, init_snow_light_accumulation, render, set_selected_entity, set_snow_depth,
set_terrain_data, update_spotlights, with_device, with_font_atlas, with_queue,
with_surface_format,
};
pub use particle_pipeline::ParticlePipeline;
pub use particle_types::ParticleInstanceRaw;
pub use text_pipeline::TextVertex;
pub use types::{DrawCall, Pipeline, Spotlight, SpotlightRaw, Uniforms, MAX_SPOTLIGHTS};
use crate::entity::EntityHandle;
@@ -15,10 +32,9 @@ use crate::paths;
use crate::postprocess::{create_blit_pipeline, create_fullscreen_quad, LowResFramebuffer};
use crate::texture::{DitherTextures, FlowmapTexture};
use pipeline::{
create_debug_lines_pipeline, create_main_pipeline, create_snow_clipmap_pipeline,
create_wireframe_pipeline,
create_debug_lines_pipeline, create_gizmo_lines_pipeline, create_main_pipeline,
create_snow_clipmap_pipeline, create_wireframe_pipeline,
};
use std::cell::RefCell;
use std::num::NonZeroU64;
const MAX_DRAW_CALLS: usize = 64;
@@ -31,13 +47,18 @@ pub struct Renderer
pub config: wgpu::SurfaceConfiguration,
framebuffer: LowResFramebuffer,
fullres_depth_view: wgpu::TextureView,
standard_pipeline: wgpu::RenderPipeline,
snow_clipmap_pipeline: wgpu::RenderPipeline,
wireframe_pipeline: Option<wgpu::RenderPipeline>,
debug_lines_pipeline: Option<wgpu::RenderPipeline>,
gizmo_lines_pipeline: Option<wgpu::RenderPipeline>,
debug_overlay: Option<debug_overlay::DebugOverlay>,
billboard_pipeline: BillboardPipeline,
particle_pipeline: ParticlePipeline,
font_atlas: font_atlas::FontAtlas,
text_pipeline: text_pipeline::TextPipeline,
wireframe_supported: bool,
uniform_buffer: wgpu::Buffer,
@@ -71,7 +92,7 @@ pub struct Renderer
dummy_snow_light_view: wgpu::TextureView,
dummy_snow_light_sampler: wgpu::Sampler,
snow_light_accumulation: Option<crate::snow_light::SnowLightAccumulation>,
snow_light_accumulation: Option<snow_light::SnowLightAccumulation>,
snow_light_bound: bool,
pub selected_entity: Option<EntityHandle>,
@@ -141,6 +162,23 @@ impl Renderer
let framebuffer =
LowResFramebuffer::new(&device, low_res_width, low_res_height, config.format);
let fullres_depth_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Full Res Depth Texture"),
size: wgpu::Extent3d {
width: config.width,
height: config.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let fullres_depth_view =
fullres_depth_texture.create_view(&wgpu::TextureViewDescriptor::default());
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Uniform Buffer"),
size: (std::mem::size_of::<Uniforms>() * MAX_DRAW_CALLS) as wgpu::BufferAddress,
@@ -494,7 +532,16 @@ impl Renderer
&bind_group_layout,
));
let gizmo_lines_pipeline = Some(create_gizmo_lines_pipeline(
&device,
config.format,
&bind_group_layout,
));
let billboard_pipeline = BillboardPipeline::new(&device, config.format);
let particle_pipeline = ParticlePipeline::new(&device, config.format);
let font_atlas = font_atlas::FontAtlas::load(&device, &queue);
let text_pipeline = text_pipeline::TextPipeline::new(&device, config.format, &font_atlas);
let debug_overlay = Some(debug_overlay::DebugOverlay::new(&device, config.format));
@@ -519,12 +566,17 @@ impl Renderer
surface,
config,
framebuffer,
fullres_depth_view,
standard_pipeline,
snow_clipmap_pipeline,
wireframe_pipeline,
debug_lines_pipeline,
gizmo_lines_pipeline,
debug_overlay,
billboard_pipeline,
particle_pipeline,
font_atlas,
text_pipeline,
wireframe_supported,
uniform_buffer,
bind_group_layout,
@@ -569,6 +621,8 @@ impl Renderer
player_position: glam::Vec3,
draw_calls: &[DrawCall],
billboard_calls: &[BillboardDrawCall],
text_vertices: &[text_pipeline::TextVertex],
particle_instances: &[ParticleInstanceRaw],
time: f32,
delta_time: f32,
debug_mode: DebugMode,
@@ -694,6 +748,10 @@ impl Renderer
.debug_lines_pipeline
.as_ref()
.unwrap_or(&self.standard_pipeline),
Pipeline::GizmoLines => self
.gizmo_lines_pipeline
.as_ref()
.unwrap_or(&self.standard_pipeline),
};
render_pass.set_pipeline(pipeline);
render_pass.set_bind_group(0, &self.bind_group, &[offset as u32]);
@@ -724,7 +782,7 @@ impl Renderer
{
if matches!(
draw_call.pipeline,
Pipeline::SnowClipmap | Pipeline::DebugLines
Pipeline::SnowClipmap | Pipeline::DebugLines | Pipeline::GizmoLines
)
{
continue;
@@ -818,7 +876,7 @@ impl Renderer
if matches!(
draw_call.pipeline,
Pipeline::SnowClipmap | Pipeline::DebugLines
Pipeline::SnowClipmap | Pipeline::DebugLines | Pipeline::GizmoLines
)
{
continue;
@@ -947,14 +1005,6 @@ impl Renderer
}
}
self.billboard_pipeline.render(
&mut encoder,
&self.queue,
&self.framebuffer.view,
&self.framebuffer.depth_view,
billboard_calls,
);
self.queue.submit(std::iter::once(encoder.finish()));
let frame = match self.surface.get_current_texture()
@@ -1005,6 +1055,63 @@ impl Renderer
}
self.queue.submit(std::iter::once(blit_encoder.finish()));
let mut overlay_encoder =
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Dialog Overlay Encoder"),
});
{
let _depth_clear = overlay_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Full Res Depth Clear"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.fullres_depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
}
self.billboard_pipeline.render(
&mut overlay_encoder,
&self.queue,
&screen_view,
&self.fullres_depth_view,
billboard_calls,
);
let view_proj_mat = *projection * *view;
self.text_pipeline.render(
&mut overlay_encoder,
&self.queue,
&screen_view,
&self.fullres_depth_view,
text_vertices,
view_proj_mat.to_cols_array_2d(),
);
if !particle_instances.is_empty()
{
self.particle_pipeline.render(
&mut overlay_encoder,
&self.queue,
&screen_view,
&self.fullres_depth_view,
particle_instances,
view_proj_mat,
*view,
);
}
self.queue.submit(std::iter::once(overlay_encoder.finish()));
frame
}
@@ -1037,12 +1144,8 @@ impl Renderer
pub fn init_snow_light_accumulation(&mut self, terrain_min: glam::Vec2, terrain_max: glam::Vec2)
{
let snow_light_accumulation = crate::snow_light::SnowLightAccumulation::new(
&self.device,
terrain_min,
terrain_max,
512,
);
let snow_light_accumulation =
snow_light::SnowLightAccumulation::new(&self.device, terrain_min, terrain_max, 512);
self.snow_light_accumulation = Some(snow_light_accumulation);
}
@@ -1093,137 +1196,3 @@ impl Renderer
self.config.width as f32 / self.config.height as f32
}
}
thread_local! {
static GLOBAL_RENDERER: RefCell<Option<Renderer>> = RefCell::new(None);
}
pub fn init(renderer: Renderer)
{
GLOBAL_RENDERER.with(|r| *r.borrow_mut() = Some(renderer));
}
pub fn with_device<F, R>(f: F) -> R
where
F: FnOnce(&wgpu::Device) -> R,
{
GLOBAL_RENDERER.with(|r| {
let renderer = r.borrow();
let renderer = renderer.as_ref().expect("Renderer not set");
f(&renderer.device)
})
}
pub fn with_queue<F, R>(f: F) -> R
where
F: FnOnce(&wgpu::Queue) -> R,
{
GLOBAL_RENDERER.with(|r| {
let renderer = r.borrow();
let renderer = renderer.as_ref().expect("Renderer not set");
f(&renderer.queue)
})
}
pub fn set_terrain_data()
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.set_terrain_data();
});
}
pub fn init_snow_light_accumulation(terrain_min: glam::Vec2, terrain_max: glam::Vec2)
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.init_snow_light_accumulation(terrain_min, terrain_max);
});
}
pub fn set_snow_depth(snow_depth_view: &wgpu::TextureView)
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.set_snow_depth(snow_depth_view);
});
}
pub fn aspect_ratio() -> f32
{
GLOBAL_RENDERER.with(|r| {
let renderer = r.borrow();
let renderer = renderer.as_ref().expect("Renderer not set");
renderer.aspect_ratio()
})
}
pub fn render(
view: &glam::Mat4,
projection: &glam::Mat4,
camera_position: glam::Vec3,
player_position: glam::Vec3,
draw_calls: &[DrawCall],
billboard_calls: &[BillboardDrawCall],
time: f32,
delta_time: f32,
debug_mode: DebugMode,
) -> wgpu::SurfaceTexture
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.render(
view,
projection,
camera_position,
player_position,
draw_calls,
billboard_calls,
time,
delta_time,
debug_mode,
)
})
}
pub fn with_surface_format<F, R>(f: F) -> R
where
F: FnOnce(wgpu::TextureFormat) -> R,
{
GLOBAL_RENDERER.with(|r| {
let renderer = r.borrow();
let renderer = renderer.as_ref().expect("Renderer not set");
f(renderer.config.format)
})
}
pub fn set_shadow_bias(bias: f32)
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.shadow_bias = bias;
});
}
pub fn update_spotlights(spotlights: Vec<Spotlight>)
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.spotlights = spotlights;
});
}
pub fn set_selected_entity(entity: Option<EntityHandle>)
{
GLOBAL_RENDERER.with(|r| {
let mut renderer = r.borrow_mut();
let renderer = renderer.as_mut().expect("Renderer not set");
renderer.selected_entity = entity;
});
}

View File

@@ -0,0 +1,241 @@
use std::mem::size_of;
use std::num::NonZeroU64;
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::paths;
use crate::render::particle_types::{ParticleInstanceRaw, ParticleVertex, MAX_PARTICLES};
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
struct ParticleUniforms
{
view_proj: [[f32; 4]; 4],
camera_right: [f32; 3],
_pad0: f32,
camera_up: [f32; 3],
_pad1: f32,
}
pub struct ParticlePipeline
{
pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
instance_buffer: wgpu::Buffer,
uniform_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
}
impl ParticlePipeline
{
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self
{
let shader_source = std::fs::read_to_string(&paths::shaders::particle())
.expect("Failed to read particle.wgsl");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Particle Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Particle Bind Group Layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: NonZeroU64::new(size_of::<ParticleUniforms>() as u64),
},
count: None,
}],
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Particle Uniform Buffer"),
size: 256,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Particle Bind Group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
}],
});
let vertices: [ParticleVertex; 4] = [
ParticleVertex {
position: [-0.5, 0.5, 0.0],
uv: [0.0, 0.0],
},
ParticleVertex {
position: [0.5, 0.5, 0.0],
uv: [1.0, 0.0],
},
ParticleVertex {
position: [0.5, -0.5, 0.0],
uv: [1.0, 1.0],
},
ParticleVertex {
position: [-0.5, -0.5, 0.0],
uv: [0.0, 1.0],
},
];
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Particle Vertex Buffer"),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let indices: [u16; 6] = [0, 1, 2, 2, 3, 0];
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Particle Index Buffer"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let instance_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Particle Instance Buffer"),
size: (MAX_PARTICLES * size_of::<ParticleInstanceRaw>()) as wgpu::BufferAddress,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Particle Pipeline Layout"),
bind_group_layouts: &[&bind_group_layout],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Particle Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[ParticleVertex::desc(), ParticleInstanceRaw::desc()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
Self {
pipeline,
vertex_buffer,
index_buffer,
instance_buffer,
uniform_buffer,
bind_group,
}
}
pub fn render(
&self,
encoder: &mut wgpu::CommandEncoder,
queue: &wgpu::Queue,
color_view: &wgpu::TextureView,
depth_view: &wgpu::TextureView,
instances: &[ParticleInstanceRaw],
view_proj: glam::Mat4,
view: glam::Mat4,
)
{
if instances.is_empty()
{
return;
}
let num_instances = instances.len().min(MAX_PARTICLES);
queue.write_buffer(
&self.instance_buffer,
0,
bytemuck::cast_slice(&instances[..num_instances]),
);
let inv_view = view.inverse();
let camera_right = inv_view.x_axis.truncate();
let camera_up = inv_view.y_axis.truncate();
let uniforms = ParticleUniforms {
view_proj: view_proj.to_cols_array_2d(),
camera_right: camera_right.into(),
_pad0: 0.0,
camera_up: camera_up.into(),
_pad1: 0.0,
};
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Particle Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, self.instance_buffer.slice(..));
pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..6, 0, 0..num_instances as u32);
}
}
}

View File

@@ -0,0 +1,89 @@
use bytemuck::{Pod, Zeroable};
pub const MAX_PARTICLES: usize = 4096;
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct ParticleVertex
{
pub position: [f32; 3],
pub uv: [f32; 2],
}
impl ParticleVertex
{
pub fn desc() -> wgpu::VertexBufferLayout<'static>
{
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ParticleVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
shader_location: 1,
format: wgpu::VertexFormat::Float32x2,
},
],
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct ParticleInstanceRaw
{
pub position: [f32; 3],
pub velocity: [f32; 3],
pub size: f32,
pub color: [f32; 4],
pub age: f32,
pub _padding: [f32; 3],
}
const _: () = assert!(std::mem::size_of::<ParticleInstanceRaw>() == 60);
impl ParticleInstanceRaw
{
pub fn desc() -> wgpu::VertexBufferLayout<'static>
{
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ParticleInstanceRaw>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 2,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
shader_location: 3,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress * 2,
shader_location: 4,
format: wgpu::VertexFormat::Float32,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress * 2
+ std::mem::size_of::<f32>() as wgpu::BufferAddress,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress * 2
+ std::mem::size_of::<f32>() as wgpu::BufferAddress
+ std::mem::size_of::<[f32; 4]>() as wgpu::BufferAddress,
shader_location: 6,
format: wgpu::VertexFormat::Float32,
},
],
}
}
}

View File

@@ -1,6 +1,92 @@
use crate::paths;
use wesl::Wesl;
fn create_lines_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout,
fragment_entry_point: &str,
label_prefix: &str,
) -> wgpu::RenderPipeline
{
let compiler = Wesl::new(&paths::SHADERS_DIR);
let shader_source = compiler
.compile(&paths::shaders::MAIN_PACKAGE.parse().unwrap())
.inspect_err(|e| eprintln!("WESL error: {e}"))
.unwrap()
.to_string();
let shader_label = format!("{label_prefix} Shader");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&shader_label),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let layout_label = format!("{label_prefix} Pipeline Layout");
let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(&layout_label),
bind_group_layouts: &[bind_group_layout],
immediate_size: 0,
});
let pipeline_label = format!("{label_prefix} Pipeline");
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&pipeline_label),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[
crate::loaders::mesh::Vertex::desc(),
crate::loaders::mesh::InstanceRaw::desc(),
],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment_entry_point),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
})
}
pub fn create_gizmo_lines_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
bind_group_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline
{
create_lines_pipeline(device, format, bind_group_layout, "fs_gizmo", "Gizmo Lines")
}
pub fn create_shadow_pipeline(
device: &wgpu::Device,
bind_group_layout: &wgpu::BindGroupLayout,
@@ -215,70 +301,7 @@ pub fn create_debug_lines_pipeline(
bind_group_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline
{
let compiler = Wesl::new(&paths::SHADERS_DIR);
let shader_source = compiler
.compile(&paths::shaders::MAIN_PACKAGE.parse().unwrap())
.inspect_err(|e| eprintln!("WESL error: {e}"))
.unwrap()
.to_string();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Debug Lines Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Debug Lines Pipeline Layout"),
bind_group_layouts: &[bind_group_layout],
immediate_size: 0,
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Debug Lines Pipeline"),
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[
crate::loaders::mesh::Vertex::desc(),
crate::loaders::mesh::InstanceRaw::desc(),
],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::LineList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::Always,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
})
create_lines_pipeline(device, format, bind_group_layout, "fs_main", "Debug Lines")
}
pub fn create_snow_clipmap_pipeline(

View File

@@ -4,10 +4,10 @@ use exr::prelude::{ReadChannels, ReadLayers};
use glam::{Vec2, Vec3};
use wgpu::util::DeviceExt;
use super::{with_device, with_queue, DrawCall, Pipeline};
use crate::{
loaders::mesh::{InstanceRaw, Mesh, Vertex},
paths,
render::{self, DrawCall, Pipeline},
texture::HeightmapTexture,
};
@@ -78,7 +78,7 @@ pub struct SnowLayer
fn create_instance_buffer() -> wgpu::Buffer
{
render::with_device(|device| {
with_device(|device| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Snow Clipmap Instance Buffer"),
size: std::mem::size_of::<InstanceRaw>() as u64,
@@ -107,13 +107,11 @@ impl SnowLayer
let (deform_pipeline, deform_bind_group, deform_params_buffer) =
Self::create_deform_pipeline(&depth_texture_view);
let heightmap_texture = render::with_device(|device| {
render::with_queue(|queue| {
HeightmapTexture::load(device, queue, &config.heightmap_path)
})
let heightmap_texture = with_device(|device| {
with_queue(|queue| HeightmapTexture::load(device, queue, &config.heightmap_path))
})?;
let depth_sampler = render::with_device(|device| {
let depth_sampler = with_device(|device| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Snow Depth Sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
@@ -226,7 +224,7 @@ impl SnowLayer
height: u32,
) -> (wgpu::Texture, wgpu::TextureView, wgpu::BindGroup)
{
render::with_device(|device| {
with_device(|device| {
let size = wgpu::Extent3d {
width,
height,
@@ -248,7 +246,7 @@ impl SnowLayer
let data_bytes: &[u8] = bytemuck::cast_slice(depth_data);
render::with_queue(|queue| {
with_queue(|queue| {
queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
@@ -300,7 +298,7 @@ impl SnowLayer
depth_texture_view: &wgpu::TextureView,
) -> (wgpu::ComputePipeline, wgpu::BindGroup, wgpu::Buffer)
{
render::with_device(|device| {
with_device(|device| {
let shader_source = std::fs::read_to_string(&paths::shaders::snow_deform())
.expect("Failed to load snow deform shader");
@@ -383,7 +381,7 @@ impl SnowLayer
depth_sampler: &wgpu::Sampler,
) -> wgpu::BindGroup
{
render::with_device(|device| {
with_device(|device| {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Snow Displacement Bind Group Layout"),
entries: &[
@@ -490,7 +488,7 @@ impl SnowLayer
}
}
let vertex_buffer = render::with_device(|device| {
let vertex_buffer = with_device(|device| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("Snow Clipmap Level {} Vertex Buffer", level)),
contents: bytemuck::cast_slice(&vertices),
@@ -498,7 +496,7 @@ impl SnowLayer
})
});
let index_buffer = render::with_device(|device| {
let index_buffer = with_device(|device| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some(&format!("Snow Clipmap Level {} Index Buffer", level)),
contents: bytemuck::cast_slice(&indices),
@@ -542,7 +540,7 @@ impl SnowLayer
}
}
render::with_queue(|queue| {
with_queue(|queue| {
for (level, clipmap_level) in self.levels.iter().enumerate()
{
let cell_size = self.clipmap_config.base_cell_size * (1u32 << level) as f32;
@@ -589,7 +587,7 @@ impl SnowLayer
pub fn deform_at_position(&self, position: Vec3, radius: f32, depth: f32)
{
render::with_queue(|queue| {
with_queue(|queue| {
let params_data = [
position.x,
position.z,
@@ -605,7 +603,7 @@ impl SnowLayer
queue.write_buffer(&self.deform_params_buffer, 0, params_bytes);
});
render::with_device(|device| {
with_device(|device| {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Snow Deform Encoder"),
});
@@ -625,7 +623,7 @@ impl SnowLayer
compute_pass.dispatch_workgroups(dispatch_x, dispatch_y, 1);
}
render::with_queue(|queue| {
with_queue(|queue| {
queue.submit(Some(encoder.finish()));
});
});

View File

@@ -1,3 +1,4 @@
use super::{Spotlight, SpotlightRaw, MAX_SPOTLIGHTS};
use crate::paths;
use bytemuck::{Pod, Zeroable};
use glam::Vec2;
@@ -13,12 +14,12 @@ struct AccumulationUniforms
delta_time: f32,
spotlight_count: u32,
_padding: u32,
light_view_projections: [[[f32; 4]; 4]; crate::render::MAX_SPOTLIGHTS],
light_view_projections: [[[f32; 4]; 4]; MAX_SPOTLIGHTS],
shadow_bias: f32,
terrain_height_scale: f32,
_padding3: f32,
_padding4: f32,
spotlights: [crate::render::SpotlightRaw; crate::render::MAX_SPOTLIGHTS],
spotlights: [SpotlightRaw; MAX_SPOTLIGHTS],
}
pub struct SnowLightAccumulation
@@ -417,7 +418,7 @@ impl SnowLightAccumulation
&mut self,
encoder: &mut wgpu::CommandEncoder,
queue: &wgpu::Queue,
spotlights: &[crate::render::Spotlight],
spotlights: &[Spotlight],
delta_time: f32,
light_view_projections: &[glam::Mat4],
shadow_bias: f32,
@@ -430,12 +431,8 @@ impl SnowLightAccumulation
self.needs_clear = false;
}
let mut spotlight_array =
[crate::render::SpotlightRaw::default(); crate::render::MAX_SPOTLIGHTS];
for (i, spotlight) in spotlights
.iter()
.take(crate::render::MAX_SPOTLIGHTS)
.enumerate()
let mut spotlight_array = [SpotlightRaw::default(); MAX_SPOTLIGHTS];
for (i, spotlight) in spotlights.iter().take(MAX_SPOTLIGHTS).enumerate()
{
spotlight_array[i] = spotlight.to_raw();
}
@@ -445,13 +442,13 @@ impl SnowLightAccumulation
terrain_max_xz: self.terrain_max.to_array(),
decay_rate: self.decay_rate,
delta_time,
spotlight_count: spotlights.len().min(crate::render::MAX_SPOTLIGHTS) as u32,
spotlight_count: spotlights.len().min(MAX_SPOTLIGHTS) as u32,
_padding: 0,
light_view_projections: {
let mut arr = [[[0.0f32; 4]; 4]; crate::render::MAX_SPOTLIGHTS];
let mut arr = [[[0.0f32; 4]; 4]; MAX_SPOTLIGHTS];
for (i, mat) in light_view_projections
.iter()
.take(crate::render::MAX_SPOTLIGHTS)
.take(MAX_SPOTLIGHTS)
.enumerate()
{
arr[i] = mat.to_cols_array_2d();

262
src/render/text_pipeline.rs Normal file
View File

@@ -0,0 +1,262 @@
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
use crate::paths;
use super::font_atlas::FontAtlas;
const MAX_TEXT_CHARS: usize = 512;
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
pub struct TextVertex
{
pub position: [f32; 3],
pub uv: [f32; 2],
}
impl TextVertex
{
fn desc() -> wgpu::VertexBufferLayout<'static>
{
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TextVertex>() as wgpu::BufferAddress,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: std::mem::size_of::<[f32; 3]>() as wgpu::BufferAddress,
shader_location: 1,
format: wgpu::VertexFormat::Float32x2,
},
],
}
}
}
#[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)]
struct ViewProjUniform
{
matrix: [[f32; 4]; 4],
}
pub struct TextPipeline
{
pipeline: wgpu::RenderPipeline,
vertex_buffer: wgpu::Buffer,
index_buffer: wgpu::Buffer,
uniform_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
}
impl TextPipeline
{
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, atlas: &FontAtlas) -> Self
{
let shader_src =
std::fs::read_to_string(&paths::shaders::text()).expect("Failed to read text.wgsl");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Text Shader"),
source: wgpu::ShaderSource::Wgsl(shader_src.into()),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Text Bind Group Layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: std::num::NonZeroU64::new(std::mem::size_of::<
ViewProjUniform,
>()
as u64),
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Text ViewProj Buffer"),
size: std::mem::size_of::<ViewProjUniform>() as wgpu::BufferAddress,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Text Bind Group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: uniform_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&atlas.texture_view),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&atlas.sampler),
},
],
});
let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Text Vertex Buffer"),
size: (MAX_TEXT_CHARS * 4 * std::mem::size_of::<TextVertex>()) as wgpu::BufferAddress,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let indices: Vec<u16> = (0..MAX_TEXT_CHARS as u16)
.flat_map(|i| {
let b = i * 4;
[b, b + 1, b + 2, b + 2, b + 3, b]
})
.collect();
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Text Index Buffer"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Text Pipeline Layout"),
bind_group_layouts: &[&bind_group_layout],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Text Pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[TextVertex::desc()],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: None,
polygon_mode: wgpu::PolygonMode::Fill,
unclipped_depth: false,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: false,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
multiview_mask: None,
cache: None,
});
Self {
pipeline,
vertex_buffer,
index_buffer,
uniform_buffer,
bind_group,
}
}
pub fn render(
&self,
encoder: &mut wgpu::CommandEncoder,
queue: &wgpu::Queue,
color_view: &wgpu::TextureView,
depth_view: &wgpu::TextureView,
vertices: &[TextVertex],
view_proj: [[f32; 4]; 4],
)
{
if vertices.is_empty()
{
return;
}
let n_chars = (vertices.len() / 4).min(MAX_TEXT_CHARS);
let used = &vertices[..n_chars * 4];
queue.write_buffer(
&self.uniform_buffer,
0,
bytemuck::cast_slice(&[ViewProjUniform { matrix: view_proj }]),
);
queue.write_buffer(&self.vertex_buffer, 0, bytemuck::cast_slice(used));
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Text Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
pass.draw_indexed(0..(n_chars * 6) as u32, 0, 0..1);
}
}

View File

@@ -143,6 +143,7 @@ pub enum Pipeline
Standard,
SnowClipmap,
DebugLines,
GizmoLines,
}
pub struct DrawCall

View File

@@ -81,6 +81,15 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
}
}
/// Fragment shader for transform gizmo lines.
/// Outputs `world_normal` directly as the RGB color — the vertex data encodes
/// axis color (X=red, Y=green, Z=blue) in the normal attribute rather than
/// storing a true geometric normal.
@fragment
fn fs_gizmo(in: VertexOutput) -> @location(0) vec4<f32> {
return vec4(in.world_normal, 1.0);
}
@group(1) @binding(0)
var heightmap_texture: texture_2d<f32>;

81
src/shaders/particle.wgsl Normal file
View File

@@ -0,0 +1,81 @@
struct ParticleUniforms
{
view_proj: mat4x4<f32>,
camera_right: vec3<f32>,
_pad0: f32,
camera_up: vec3<f32>,
_pad1: f32,
}
@group(0) @binding(0)
var<uniform> uniforms: ParticleUniforms;
struct VertexIn
{
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
}
struct ParticleInstance
{
@location(2) position: vec3<f32>,
@location(3) velocity: vec3<f32>,
@location(4) size: f32,
@location(5) color: vec4<f32>,
@location(6) age: f32,
}
struct VertexOut
{
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) age: f32,
}
@vertex
fn vs_main(in: VertexIn, inst: ParticleInstance) -> VertexOut
{
var out: VertexOut;
let stretch = length(inst.velocity) * 0.05;
let vel_dir = normalize(select(inst.velocity, vec3<f32>(0.0, 1.0, 0.0),
length(inst.velocity) > 0.001));
let world_pos = inst.position
+ uniforms.camera_right * (in.uv.x - 0.5) * inst.size
+ uniforms.camera_up * (in.uv.y - 0.5) * inst.size
+ vel_dir * (in.uv.y - 0.5) * stretch;
out.clip_pos = uniforms.view_proj * vec4<f32>(world_pos, 1.0);
out.uv = in.uv;
out.color = inst.color;
out.age = inst.age;
return out;
}
var<private> bayer: array<f32, 16> = array<f32, 16>(
0.0, 8.0, 2.0, 10.0,
12.0, 4.0, 14.0, 6.0,
3.0, 11.0, 1.0, 9.0,
15.0, 7.0, 13.0, 5.0,
);
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32>
{
let centered = in.uv * 2.0 - vec2<f32>(1.0, 1.0);
let dist = length(centered);
let circle = 1.0 - smoothstep(0.7, 1.0, dist);
let age_alpha = (1.0 - in.age) * in.color.a * circle;
let fx = u32(in.clip_pos.x) % 4u;
let fy = u32(in.clip_pos.y) % 4u;
let thresh = bayer[fy * 4u + fx] / 16.0;
if age_alpha <= thresh
{
discard;
}
return vec4<f32>(in.color.rgb, 1.0);
}

45
src/shaders/text.wgsl Normal file
View File

@@ -0,0 +1,45 @@
struct Uniforms
{
view_proj: mat4x4<f32>,
}
@group(0) @binding(0)
var<uniform> u: Uniforms;
@group(0) @binding(1)
var glyph_atlas: texture_2d<f32>;
@group(0) @binding(2)
var glyph_sampler: sampler;
struct VertexIn
{
@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>,
}
struct VertexOut
{
@builtin(position) clip_pos: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_main(in: VertexIn) -> VertexOut
{
var out: VertexOut;
out.clip_pos = u.view_proj * vec4<f32>(in.position, 1.0);
out.uv = in.uv;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32>
{
let coverage = textureSample(glyph_atlas, glyph_sampler, in.uv).r;
if coverage < 0.004
{
discard;
}
return vec4<f32>(1.0, 1.0, 1.0, coverage);
}

2
src/states/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod player_states;
pub mod state;

View File

@@ -7,7 +7,7 @@ use crate::components::player_states::{
};
use crate::entity::EntityHandle;
use crate::physics::PhysicsManager;
use crate::state::PlayerState;
use crate::states::state::State;
use crate::world::World;
pub const LEAP_DURATION: f32 = 0.18;
@@ -15,7 +15,7 @@ pub const ROLL_DURATION: f32 = 0.42;
const LEAP_SPEED: f32 = 18.0;
const ROLL_SPEED: f32 = 14.0;
impl PlayerState for IdleState
impl State for IdleState
{
fn tick_time(&mut self, delta: f32)
{
@@ -87,7 +87,7 @@ impl PlayerState for IdleState
}
}
impl PlayerState for WalkingState
impl State for WalkingState
{
fn tick_time(&mut self, delta: f32)
{
@@ -204,7 +204,7 @@ impl PlayerState for WalkingState
}
}
impl PlayerState for JumpingState
impl State for JumpingState
{
fn tick_time(&mut self, delta: f32)
{
@@ -283,7 +283,7 @@ impl PlayerState for JumpingState
}
}
impl PlayerState for FallingState
impl State for FallingState
{
fn tick_time(&mut self, delta: f32)
{
@@ -356,7 +356,7 @@ impl PlayerState for FallingState
}
}
impl PlayerState for LeapingState
impl State for LeapingState
{
fn tick_time(&mut self, delta: f32)
{
@@ -430,7 +430,7 @@ impl PlayerState for LeapingState
}
}
impl PlayerState for RollingState
impl State for RollingState
{
fn tick_time(&mut self, delta: f32)
{

View File

@@ -4,7 +4,7 @@ use std::collections::HashMap;
use crate::entity::EntityHandle;
use crate::world::{Storage, World};
pub trait PlayerState
pub trait State
{
fn tick_time(&mut self, _delta: f32) {}
fn on_enter(&mut self, _world: &mut World, _entity: EntityHandle) {}
@@ -54,7 +54,7 @@ impl StateMachine
}
}
pub fn register_state<S: PlayerState + Default + 'static>(
pub fn register_state<S: State + Default + 'static>(
&mut self,
storage: fn(&mut World) -> &mut Storage<S>,
)

View File

@@ -1,17 +1,32 @@
use glam::Vec3;
use crate::components::camera::{CameraComponent, CameraTransition};
use crate::components::intent::{CameraTransitionIntent, FollowPlayerIntent, StopFollowingIntent};
use crate::components::FollowComponent;
use crate::entity::EntityHandle;
use crate::physics::PhysicsManager;
use crate::utility::input::InputState;
use crate::world::{Transform, World};
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn camera_view_matrix(world: &World) -> Option<glam::Mat4>
const CAMERA_GROUND_OFFSET: f32 = 2.0;
pub fn camera_view_matrix(
cameras: &Storage<CameraComponent>,
transforms: &Storage<Transform>,
follows: &Storage<FollowComponent>,
) -> Option<glam::Mat4>
{
let (camera_entity, camera_component) = world.active_camera()?;
let camera_transform = world.transforms.get(camera_entity)?;
let (camera_entity, camera_component) = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.map(|(e, c)| (*e, c))?;
let camera_transform = transforms.get(camera_entity)?;
if let Some(follow) = world.follows.get(camera_entity)
if let Some(follow) = follows.get(camera_entity)
{
if let Some(target_transform) = world.transforms.get(follow.target)
if let Some(target_transform) = transforms.get(follow.target)
{
return Some(glam::Mat4::look_at_rh(
camera_transform.position,
@@ -30,13 +45,17 @@ pub fn camera_view_matrix(world: &World) -> Option<glam::Mat4>
))
}
pub fn camera_input_system(world: &mut World, input_state: &InputState)
pub fn camera_input_system(
cameras: &mut Storage<CameraComponent>,
follows: &Storage<FollowComponent>,
input_state: &InputState,
)
{
let cameras: Vec<_> = world.cameras.all();
let camera_entities: Vec<_> = cameras.all();
for camera_entity in cameras
for camera_entity in camera_entities
{
if let Some(camera) = world.cameras.get_mut(camera_entity)
if let Some(camera) = cameras.get_mut(camera_entity)
{
if !camera.is_active
{
@@ -45,7 +64,7 @@ pub fn camera_input_system(world: &mut World, input_state: &InputState)
if input_state.mouse_delta.0.abs() > 0.0 || input_state.mouse_delta.1.abs() > 0.0
{
let is_following = world.follows.get(camera_entity).is_some();
let is_following = follows.get(camera_entity).is_some();
camera.yaw += input_state.mouse_delta.0 * 0.0008;
@@ -66,20 +85,61 @@ pub fn camera_input_system(world: &mut World, input_state: &InputState)
}
}
pub fn camera_follow_system(world: &mut World)
pub fn camera_intent_system(
follow_player_intents: &mut Storage<FollowPlayerIntent>,
stop_following_intents: &mut Storage<StopFollowingIntent>,
camera_transition_intents: &mut Storage<CameraTransitionIntent>,
follows: &mut Storage<FollowComponent>,
transforms: &mut Storage<Transform>,
cameras: &mut Storage<CameraComponent>,
player_tags: &Storage<()>,
camera_transitions: &mut Storage<CameraTransition>,
)
{
let camera_entities: Vec<_> = world.follows.all();
let follow_entities: Vec<EntityHandle> = follow_player_intents.all();
for entity in follow_entities
{
start_camera_following(follows, transforms, cameras, player_tags, entity);
follow_player_intents.remove(entity);
}
let stop_entities: Vec<EntityHandle> = stop_following_intents.all();
for entity in stop_entities
{
stop_camera_following(follows, transforms, cameras, entity);
stop_following_intents.remove(entity);
}
let transition_entities: Vec<EntityHandle> = camera_transition_intents.all();
for entity in transition_entities
{
let duration = camera_transition_intents
.get(entity)
.map(|i| i.duration)
.unwrap_or(0.5);
start_camera_transition(camera_transitions, transforms, cameras, entity, duration);
camera_transition_intents.remove(entity);
}
}
pub fn camera_follow_system(
follows: &mut Storage<FollowComponent>,
cameras: &Storage<CameraComponent>,
transforms: &mut Storage<Transform>,
)
{
let camera_entities: Vec<_> = follows.all();
for camera_entity in camera_entities
{
if let Some(camera) = world.cameras.get(camera_entity)
if let Some(camera) = cameras.get(camera_entity)
{
if let Some(follow) = world.follows.get(camera_entity)
if let Some(follow) = follows.get(camera_entity)
{
let target_entity = follow.target;
let offset = follow.offset.position;
if let Some(target_transform) = world.transforms.get(target_entity)
if let Some(target_transform) = transforms.get(target_entity)
{
let target_position = target_transform.position;
let distance = offset.length();
@@ -92,13 +152,11 @@ pub fn camera_follow_system(world: &mut World)
let new_offset = Vec3::new(offset_x, offset_y, offset_z);
world
.transforms
.with_mut(camera_entity, |camera_transform| {
camera_transform.position = target_position + new_offset;
});
transforms.with_mut(camera_entity, |camera_transform| {
camera_transform.position = target_position + new_offset;
});
world.follows.components.get_mut(&camera_entity).map(|f| {
follows.components.get_mut(&camera_entity).map(|f| {
f.offset.position = new_offset;
});
}
@@ -107,13 +165,29 @@ pub fn camera_follow_system(world: &mut World)
}
}
pub fn camera_noclip_system(world: &mut World, input_state: &InputState, delta: f32)
pub fn camera_noclip_system(
cameras: &Storage<CameraComponent>,
follows: &Storage<FollowComponent>,
transforms: &mut Storage<Transform>,
input_state: &InputState,
delta: f32,
)
{
let cameras: Vec<_> = world.cameras.all();
for camera_entity in cameras
if !input_state.mouse_captured
{
if let Some(camera) = world.cameras.get(camera_entity)
return;
}
let camera_entities: Vec<_> = cameras.all();
for camera_entity in camera_entities
{
if follows.get(camera_entity).is_some()
{
continue;
}
if let Some(camera) = cameras.get(camera_entity)
{
if !camera.is_active
{
@@ -157,7 +231,7 @@ pub fn camera_noclip_system(world: &mut World, input_state: &InputState, delta:
speed *= 10.0;
}
if let Some(camera_transform) = world.transforms.get_mut(camera_entity)
if let Some(camera_transform) = transforms.get_mut(camera_entity)
{
camera_transform.position += forward * input_vec.z * speed;
camera_transform.position += right * input_vec.x * speed;
@@ -167,28 +241,34 @@ pub fn camera_noclip_system(world: &mut World, input_state: &InputState, delta:
}
}
pub fn start_camera_following(world: &mut World, camera_entity: crate::entity::EntityHandle)
fn start_camera_following(
follows: &mut Storage<FollowComponent>,
transforms: &mut Storage<Transform>,
cameras: &mut Storage<CameraComponent>,
player_tags: &Storage<()>,
camera_entity: EntityHandle,
)
{
if let Some(camera_transform) = world.transforms.get(camera_entity)
if let Some(camera_transform) = transforms.get(camera_entity)
{
let player_entities = world.player_tags.all();
let player_entities = player_tags.all();
if let Some(&player_entity) = player_entities.first()
{
if let Some(target_transform) = world.transforms.get(player_entity)
if let Some(target_transform) = transforms.get(player_entity)
{
let offset = camera_transform.position - target_transform.position;
let distance = offset.length();
if distance > 0.0
{
if let Some(camera) = world.cameras.get_mut(camera_entity)
if let Some(camera) = cameras.get_mut(camera_entity)
{
camera.pitch = (offset.y / distance).asin();
camera.yaw = offset.z.atan2(offset.x) + std::f32::consts::PI;
}
}
world.follows.insert(
follows.insert(
camera_entity,
FollowComponent {
target: player_entity,
@@ -202,20 +282,25 @@ pub fn start_camera_following(world: &mut World, camera_entity: crate::entity::E
}
}
pub fn stop_camera_following(world: &mut World, camera_entity: crate::entity::EntityHandle)
fn stop_camera_following(
follows: &mut Storage<FollowComponent>,
transforms: &Storage<Transform>,
cameras: &mut Storage<CameraComponent>,
camera_entity: EntityHandle,
)
{
if let Some(follow) = world.follows.get(camera_entity)
if let Some(follow) = follows.get(camera_entity)
{
let target_entity = follow.target;
if let Some(camera_transform) = world.transforms.get(camera_entity)
if let Some(camera_transform) = transforms.get(camera_entity)
{
if let Some(target_transform) = world.transforms.get(target_entity)
if let Some(target_transform) = transforms.get(target_entity)
{
let look_direction =
(target_transform.position - camera_transform.position).normalize();
if let Some(camera) = world.cameras.get_mut(camera_entity)
if let Some(camera) = cameras.get_mut(camera_entity)
{
camera.yaw = look_direction.z.atan2(look_direction.x);
camera.pitch = look_direction.y.asin();
@@ -223,6 +308,156 @@ pub fn stop_camera_following(world: &mut World, camera_entity: crate::entity::En
}
}
world.follows.remove(camera_entity);
follows.remove(camera_entity);
}
}
pub fn camera_ground_clamp_system(
cameras: &Storage<CameraComponent>,
follows: &Storage<FollowComponent>,
transforms: &mut Storage<Transform>,
)
{
let camera_entity = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.map(|(e, _)| *e);
let Some(camera_entity) = camera_entity
else
{
return;
};
if follows.get(camera_entity).is_none()
{
return;
}
transforms.with_mut(camera_entity, |t| {
let ground_y =
PhysicsManager::get_terrain_height_at(t.position.x, t.position.z).unwrap_or(0.0);
let min_y = ground_y + CAMERA_GROUND_OFFSET;
if t.position.y < min_y
{
t.position.y = min_y;
}
});
}
fn start_camera_transition(
camera_transitions: &mut Storage<CameraTransition>,
transforms: &mut Storage<Transform>,
cameras: &mut Storage<CameraComponent>,
camera_entity: EntityHandle,
duration: f32,
)
{
let Some(camera) = cameras.get(camera_entity)
else
{
return;
};
let source_yaw = camera.yaw;
let source_pitch = camera.pitch;
let source_position = transforms
.with(camera_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
camera_transitions.insert(
camera_entity,
CameraTransition {
source_position,
source_yaw,
source_pitch,
elapsed: 0.0,
duration,
},
);
}
pub fn camera_transition_system(
camera_transitions: &mut Storage<CameraTransition>,
transforms: &mut Storage<Transform>,
cameras: &mut Storage<CameraComponent>,
delta: f32,
)
{
let entities: Vec<EntityHandle> = camera_transitions.all();
for entity in entities
{
let finished = {
let Some(transition) = camera_transitions.get_mut(entity)
else
{
continue;
};
transition.elapsed += delta;
let t = (transition.elapsed / transition.duration).min(1.0);
let t = smoothstep(t);
let source_position = transition.source_position;
let source_yaw = transition.source_yaw;
let source_pitch = transition.source_pitch;
let finished = t >= 1.0;
transforms.with_mut(entity, |transform| {
transform.position = source_position.lerp(transform.position, t);
});
if let Some(camera) = cameras.get_mut(entity)
{
camera.yaw = lerp_angle(source_yaw, camera.yaw, t);
camera.pitch = source_pitch + (camera.pitch - source_pitch) * t;
}
if !finished
{
if let Some(transition) = camera_transitions.get_mut(entity)
{
let pos = transforms
.with(entity, |tr| tr.position)
.unwrap_or(Vec3::ZERO);
let cam = cameras.get(entity);
transition.source_position = pos;
if let Some(cam) = cam
{
transition.source_yaw = cam.yaw;
transition.source_pitch = cam.pitch;
}
}
}
finished
};
if finished
{
camera_transitions.remove(entity);
}
}
}
fn smoothstep(t: f32) -> f32
{
t * t * (3.0 - 2.0 * t)
}
fn lerp_angle(from: f32, to: f32, t: f32) -> f32
{
let mut diff = to - from;
while diff > std::f32::consts::PI
{
diff -= std::f32::consts::TAU;
}
while diff < -std::f32::consts::PI
{
diff += std::f32::consts::TAU;
}
from + diff * t
}

View File

@@ -1,346 +0,0 @@
use bladeink::story::Story;
use glam::Vec3;
use crate::components::dialog::{
DialogBubbleComponent, DialogPhase, DialogProjectileComponent, ParryButton,
};
use crate::components::trigger::TriggerEventKind;
use crate::entity::EntityHandle;
use crate::world::{Transform, World};
const DEFAULT_DISPLAY_TIME: f32 = 3.0;
const PARRY_TAG_PREFIX: &str = "parry:";
const TIMER_TAG_PREFIX: &str = "timer:";
pub fn dialog_system(world: &mut World, delta: f32)
{
process_trigger_events(world);
tick_displaying_bubbles(world, delta);
process_outcomes(world);
}
fn process_trigger_events(world: &mut World)
{
let events: Vec<_> = world.trigger_events.iter().cloned().collect();
for event in events
{
let has_source = world.dialog_sources.get(event.trigger_entity).is_some();
if !has_source
{
continue;
}
match event.kind
{
TriggerEventKind::Entered =>
{
let already_active = world.bubble_tags.all().iter().any(|&b| {
world
.dialog_bubbles
.with(b, |db| db.character_entity == event.trigger_entity)
.unwrap_or(false)
});
if already_active
{
continue;
}
spawn_bubble(world, event.trigger_entity);
}
TriggerEventKind::Exited =>
{
despawn_bubbles_for_character(world, event.trigger_entity);
}
}
}
}
fn spawn_bubble(world: &mut World, character_entity: EntityHandle)
{
let ink_json = match world
.dialog_sources
.with(character_entity, |s| s.ink_json.clone())
{
Some(json) => json,
None => return,
};
let mut story = match Story::new(&ink_json)
{
Ok(s) => s,
Err(e) =>
{
eprintln!("Failed to load ink story: {e}");
return;
}
};
let (text, parry, display_time) = advance_story(&mut story);
let character_pos = world
.transforms
.with(character_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let bubble_entity = world.spawn();
world.transforms.insert(
bubble_entity,
Transform::from_position(character_pos + Vec3::new(0.0, 2.5, 0.0)),
);
world
.names
.insert(bubble_entity, "DialogBubble".to_string());
world.bubble_tags.insert(bubble_entity, ());
world.dialog_bubbles.insert(
bubble_entity,
DialogBubbleComponent {
story,
character_entity,
current_text: text,
phase: DialogPhase::Displaying {
timer: display_time,
},
correct_parry: parry,
display_time,
},
);
println!("Dialog bubble spawned for character {character_entity}");
}
fn despawn_bubbles_for_character(world: &mut World, character_entity: EntityHandle)
{
let bubbles: Vec<EntityHandle> = world.bubble_tags.all();
for bubble_entity in bubbles
{
let matches = world
.dialog_bubbles
.with(bubble_entity, |b| b.character_entity == character_entity)
.unwrap_or(false);
if !matches
{
continue;
}
if let Some(bubble) = world.dialog_bubbles.get(bubble_entity)
{
if let DialogPhase::ProjectileInFlight { projectile_entity } = bubble.phase
{
world.despawn(projectile_entity);
}
}
world.despawn(bubble_entity);
println!("Dialog bubble despawned for character {character_entity}");
}
}
fn tick_displaying_bubbles(world: &mut World, delta: f32)
{
let bubbles: Vec<EntityHandle> = world.bubble_tags.all();
for bubble_entity in bubbles
{
let character_entity = match world.dialog_bubbles.with(bubble_entity, |b| {
if matches!(b.phase, DialogPhase::Displaying { .. })
{
Some(b.character_entity)
}
else
{
None
}
})
{
Some(Some(e)) => e,
_ => continue,
};
let expired = world
.dialog_bubbles
.with_mut(bubble_entity, |b| {
if let DialogPhase::Displaying { ref mut timer } = b.phase
{
*timer -= delta;
*timer <= 0.0
}
else
{
false
}
})
.unwrap_or(false);
if expired
{
let correct_parry = match world
.dialog_bubbles
.with(bubble_entity, |b| b.correct_parry)
{
Some(Some(p)) => p,
_ =>
{
world.despawn(bubble_entity);
continue;
}
};
let character_pos = world
.transforms
.with(character_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let projectile_entity = world.spawn();
world.transforms.insert(
projectile_entity,
Transform::from_position(character_pos + Vec3::new(0.0, 1.5, 0.0)),
);
world
.names
.insert(projectile_entity, "DialogProjectile".to_string());
world.projectile_tags.insert(projectile_entity, ());
world.dialog_projectiles.insert(
projectile_entity,
DialogProjectileComponent {
bubble_entity,
correct_parry,
parry_window_open: false,
},
);
world.dialog_bubbles.with_mut(bubble_entity, |b| {
b.phase = DialogPhase::ProjectileInFlight { projectile_entity };
});
println!(
"Dialog projectile spawned, correct parry: {:?}",
correct_parry
);
}
}
}
fn process_outcomes(world: &mut World)
{
let outcomes: Vec<_> = world.dialog_outcomes.drain(..).collect();
for event in outcomes
{
let bubble_entity = event.bubble_entity;
if world.dialog_bubbles.get(bubble_entity).is_none()
{
continue;
}
let choice_tag = event.outcome.to_choice_tag();
let next = world.dialog_bubbles.with_mut(bubble_entity, |b| {
let choices = b.story.get_current_choices();
let idx = choices
.iter()
.position(|c| c.tags.iter().any(|t| t.trim() == choice_tag));
if let Some(idx) = idx
{
let _ = b.story.choose_choice_index(idx);
}
else
{
println!("No choice found for outcome tag '{choice_tag}', using first available");
if !choices.is_empty()
{
let _ = b.story.choose_choice_index(0);
}
}
if b.story.can_continue()
{
let (text, parry, display_time) = advance_story(&mut b.story);
Some((text, parry, display_time))
}
else
{
None
}
});
match next
{
Some(Some((text, parry, display_time))) =>
{
world.dialog_bubbles.with_mut(bubble_entity, |b| {
b.current_text = text;
b.correct_parry = parry;
b.display_time = display_time;
b.phase = DialogPhase::Displaying {
timer: display_time,
};
});
println!(
"Dialog advanced: '{}'",
world
.dialog_bubbles
.with(bubble_entity, |b| b.current_text.clone())
.unwrap_or_default()
);
}
Some(None) =>
{
world.despawn(bubble_entity);
println!("Dialog story finished");
}
None =>
{}
}
}
}
fn advance_story(story: &mut Story) -> (String, Option<ParryButton>, f32)
{
let mut full_text = String::new();
let mut parry: Option<ParryButton> = None;
let mut display_time = DEFAULT_DISPLAY_TIME;
while story.can_continue()
{
match story.cont()
{
Ok(line) => full_text.push_str(&line),
Err(e) =>
{
eprintln!("Story error: {e}");
break;
}
}
if let Ok(tags) = story.get_current_tags()
{
for tag in tags
{
let tag = tag.trim().to_string();
let tag = tag.as_str();
if let Some(val) = tag.strip_prefix(PARRY_TAG_PREFIX)
{
parry = ParryButton::from_tag(val.trim());
}
else if let Some(val) = tag.strip_prefix(TIMER_TAG_PREFIX)
{
if let Ok(t) = val.trim().parse::<f32>()
{
display_time = t;
}
}
}
}
}
(full_text.trim().to_string(), parry, display_time)
}

View File

@@ -1,39 +1,64 @@
use glam::Vec3;
use crate::world::World;
use crate::components::camera::CameraComponent;
use crate::components::intent::CameraTransitionIntent;
use crate::entity::EntityHandle;
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn dialog_camera_transition_system(
bubble_tags: &Storage<()>,
camera_transition_intents: &mut Storage<CameraTransitionIntent>,
was_dialog_active: &mut bool,
camera_entity: EntityHandle,
)
{
let dialog_active = !bubble_tags.all().is_empty();
if dialog_active != *was_dialog_active
{
camera_transition_intents.insert(camera_entity, CameraTransitionIntent { duration: 0.8 });
*was_dialog_active = dialog_active;
}
}
const CAMERA_LAG: f32 = 4.0;
const VERTICAL_BIAS: f32 = 0.4;
const MIN_DISTANCE: f32 = 8.0;
const MAX_DISTANCE: f32 = 24.0;
pub fn dialog_camera_system(world: &mut World, delta: f32)
pub fn dialog_camera_system(
cameras: &mut Storage<CameraComponent>,
transforms: &mut Storage<Transform>,
bubble_tags: &Storage<()>,
player_pos: Vec3,
delta: f32,
)
{
let Some((camera_entity, _)) = world.active_camera()
let camera_entity = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.map(|(e, _)| *e);
let Some(camera_entity) = camera_entity
else
{
return;
};
let player_pos = world.player_position();
let character_positions: Vec<Vec3> = world
.bubble_tags
let bubble_positions: Vec<Vec3> = bubble_tags
.all()
.iter()
.filter_map(|&bubble| {
let char_entity = world.dialog_bubbles.with(bubble, |b| b.character_entity)?;
world.transforms.with(char_entity, |t| t.position)
})
.filter_map(|&bubble| transforms.with(bubble, |t| t.position))
.collect();
if character_positions.is_empty()
if bubble_positions.is_empty()
{
return;
}
let all_positions: Vec<Vec3> = std::iter::once(player_pos)
.chain(character_positions.iter().copied())
.chain(bubble_positions.iter().copied())
.collect();
let centroid =
@@ -52,20 +77,19 @@ pub fn dialog_camera_system(world: &mut World, delta: f32)
let target_camera_pos =
centroid + camera_back_dir * camera_distance + Vec3::Y * camera_distance * VERTICAL_BIAS;
let current_camera_pos = world
.transforms
let current_camera_pos = transforms
.with(camera_entity, |t| t.position)
.unwrap_or(target_camera_pos);
let smoothed = current_camera_pos.lerp(target_camera_pos, (CAMERA_LAG * delta).min(1.0));
world.transforms.with_mut(camera_entity, |t| {
transforms.with_mut(camera_entity, |t| {
t.position = smoothed;
});
let look_target = centroid + Vec3::Y * 1.0;
let look_target = centroid;
if let Some(camera) = world.cameras.get_mut(camera_entity)
if let Some(camera) = cameras.get_mut(camera_entity)
{
let look_dir = (look_target - smoothed).normalize_or(-Vec3::Z);
camera.yaw = look_dir.z.atan2(look_dir.x);

View File

@@ -1,38 +1,54 @@
use std::f32::consts::PI;
use glam::Vec3;
use crate::components::dialog::{DialogOutcome, DialogOutcomeEvent, ParryButton};
use crate::components::dialog::{
DialogOutcome, DialogOutcomeEvent, DialogProjectileComponent, ParryButton,
};
use crate::components::particle::{ParticleEmitterConfig, SpawnParticleIntent};
use crate::components::player_states::{LeapingState, RollingState};
use crate::entity::EntityHandle;
use crate::utility::input::InputState;
use crate::world::World;
use crate::utility::transform::Transform;
use crate::world::Storage;
const PROJECTILE_SPEED: f32 = 6.0;
const PARRY_WINDOW_RADIUS: f32 = 3.5;
const HIT_RADIUS: f32 = 1.2;
pub fn dialog_projectile_system(world: &mut World, input_state: &InputState)
pub fn dialog_projectile_system(
player_tags: &Storage<()>,
transforms: &mut Storage<Transform>,
projectile_tags: &mut Storage<()>,
dialog_projectiles: &mut Storage<DialogProjectileComponent>,
spawn_particle_intents: &mut Vec<SpawnParticleIntent>,
dialog_outcomes: &mut Vec<DialogOutcomeEvent>,
leaping_states: &Storage<LeapingState>,
rolling_states: &Storage<RollingState>,
input_state: &InputState,
)
{
let player_entity = world.player_tags.all().into_iter().next();
let player_entity = player_tags.all().into_iter().next();
let Some(player_entity) = player_entity
else
{
return;
};
let player_pos = world
.transforms
let player_pos = transforms
.with(player_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let player_is_evading = is_player_evading(world, player_entity);
let player_is_evading =
leaping_states.get(player_entity).is_some() || rolling_states.get(player_entity).is_some();
let projectiles: Vec<EntityHandle> = world.projectile_tags.all();
let projectiles: Vec<EntityHandle> = projectile_tags.all();
let mut outcomes: Vec<DialogOutcomeEvent> = Vec::new();
let mut to_despawn: Vec<EntityHandle> = Vec::new();
for proj_entity in projectiles
{
let proj_pos = match world.transforms.with(proj_entity, |t| t.position)
let proj_pos = match transforms.with(proj_entity, |t| t.position)
{
Some(p) => p,
None => continue,
@@ -41,17 +57,20 @@ pub fn dialog_projectile_system(world: &mut World, input_state: &InputState)
let to_player = player_pos - proj_pos;
let distance = to_player.length();
let window_open = world
.dialog_projectiles
let window_open = dialog_projectiles
.with(proj_entity, |p| p.parry_window_open)
.unwrap_or(false);
if window_open
{
if let Some(outcome) = resolve_parry(world, proj_entity, input_state, player_is_evading)
if let Some(outcome) = resolve_parry(
dialog_projectiles,
proj_entity,
input_state,
player_is_evading,
)
{
let bubble_entity = world
.dialog_projectiles
let bubble_entity = dialog_projectiles
.with(proj_entity, |p| p.bubble_entity)
.unwrap();
outcomes.push(DialogOutcomeEvent {
@@ -65,8 +84,7 @@ pub fn dialog_projectile_system(world: &mut World, input_state: &InputState)
if distance < HIT_RADIUS
{
let bubble_entity = world
.dialog_projectiles
let bubble_entity = dialog_projectiles
.with(proj_entity, |p| p.bubble_entity)
.unwrap();
let outcome = if player_is_evading
@@ -87,7 +105,7 @@ pub fn dialog_projectile_system(world: &mut World, input_state: &InputState)
if distance < PARRY_WINDOW_RADIUS
{
world.dialog_projectiles.with_mut(proj_entity, |p| {
dialog_projectiles.with_mut(proj_entity, |p| {
p.parry_window_open = true;
});
}
@@ -101,21 +119,32 @@ pub fn dialog_projectile_system(world: &mut World, input_state: &InputState)
Vec3::ZERO
};
world.transforms.with_mut(proj_entity, |t| {
transforms.with_mut(proj_entity, |t| {
t.position += direction * PROJECTILE_SPEED * (1.0 / 60.0);
});
let proj_pos = transforms.with(proj_entity, |t| t.position);
if let Some(pos) = proj_pos
{
spawn_particle_intents.push(SpawnParticleIntent {
origin: pos,
config: projectile_swarm_config(),
});
}
}
for entity in to_despawn
{
world.despawn(entity);
transforms.remove(entity);
projectile_tags.remove(entity);
dialog_projectiles.remove(entity);
}
world.dialog_outcomes.extend(outcomes);
dialog_outcomes.extend(outcomes);
}
fn resolve_parry(
world: &World,
dialog_projectiles: &Storage<DialogProjectileComponent>,
proj_entity: EntityHandle,
input_state: &InputState,
player_is_evading: bool,
@@ -126,9 +155,7 @@ fn resolve_parry(
return Some(DialogOutcome::Evaded);
}
let correct_parry = world
.dialog_projectiles
.with(proj_entity, |p| p.correct_parry)?;
let correct_parry = dialog_projectiles.with(proj_entity, |p| p.correct_parry)?;
if input_state.i_just_pressed
{
@@ -175,8 +202,17 @@ fn resolve_parry(
None
}
fn is_player_evading(world: &World, player_entity: EntityHandle) -> bool
fn projectile_swarm_config() -> ParticleEmitterConfig
{
world.leaping_states.get(player_entity).is_some()
|| world.rolling_states.get(player_entity).is_some()
ParticleEmitterConfig {
burst_count: 3,
lifetime: 0.3..0.6,
speed: 0.5..2.0,
direction: None,
direction_spread: PI,
gravity: 0.0,
size: 0.05..0.15,
color_start: [1.0, 0.3, 0.1, 1.0],
color_end: [0.2, 0.05, 0.0, 0.0],
}
}

View File

@@ -1,109 +1,159 @@
use glam::{Mat4, Vec3};
use crate::components::dialog::DialogBubbleComponent;
use crate::render::billboard::{BillboardDrawCall, BillboardVertex, BubbleUniforms};
use crate::world::World;
use crate::render::{with_font_atlas, TextVertex};
use crate::utility::transform::Transform;
use crate::world::Storage;
const BUBBLE_WIDTH: f32 = 2.2;
const BUBBLE_HEIGHT: f32 = 1.1;
const BODY_FRAC: f32 = 0.78;
const MAX_BUBBLE_WIDTH: f32 = 8.0;
const MIN_BUBBLE_WIDTH: f32 = 0.5;
const TAIL_HEIGHT: f32 = 0.242;
const CORNER_R: f32 = 0.18;
const BORDER_W: f32 = 0.06;
const HEIGHT_OFFSET: f32 = 8.2;
const HEIGHT_OFFSET: f32 = 0.0;
const FILL_COLOR: [f32; 4] = [0.05, 0.05, 0.05, 1.0];
const BORDER_COLOR: [f32; 4] = [1.0, 1.0, 1.0, 1.0];
const CHAR_WORLD_HEIGHT: f32 = 0.84;
const TEXT_PADDING: f32 = 0.06;
const LINE_SPACING: f32 = 0.01;
pub fn dialog_bubble_render_system(
world: &World,
transforms: &Storage<Transform>,
dialog_bubbles: &Storage<DialogBubbleComponent>,
bubble_tags: &Storage<()>,
camera_pos: Vec3,
view_proj: Mat4,
) -> Vec<BillboardDrawCall>
) -> (Vec<BillboardDrawCall>, Vec<TextVertex>)
{
let mut calls = Vec::new();
let mut all_text: Vec<TextVertex> = Vec::new();
for bubble_entity in world.bubble_tags.all()
for bubble_entity in bubble_tags.all()
{
let character_entity = match world
.dialog_bubbles
.with(bubble_entity, |b| b.character_entity)
{
Some(e) => e,
None => continue,
};
let character_pos = match world.transforms.with(character_entity, |t| t.position)
let bubble_pos = match transforms.with(bubble_entity, |t| t.position)
{
Some(p) => p,
None => continue,
};
let body_half_h = BUBBLE_HEIGHT * BODY_FRAC * 0.5;
let tail_height = BUBBLE_HEIGHT * (1.0 - BODY_FRAC);
let anchor = character_pos + Vec3::Y * (HEIGHT_OFFSET + body_half_h);
let to_camera = camera_pos - anchor;
let forward = if to_camera.length_squared() > 1e-6
let text = match dialog_bubbles.with(bubble_entity, |b| b.current_text.clone())
{
to_camera.normalize()
}
else
{
Vec3::Z
Some(t) => t,
None => continue,
};
let up_ref = Vec3::Y;
let right = if forward.abs().dot(up_ref) > 0.99
{
Vec3::X
}
else
{
up_ref.cross(forward).normalize()
};
let up = forward.cross(right).normalize();
let (draw_call, text_verts) = with_font_atlas(|atlas| {
let char_w = CHAR_WORLD_HEIGHT * atlas.aspect();
let half_w = BUBBLE_WIDTH * 0.5;
let total_down = body_half_h + tail_height;
// First-pass: measure text at max width using a conservative padding estimate.
// BORDER_W is applied against body_height (the smaller dimension); TAIL_HEIGHT
// is a safe lower-bound for body_height, giving a slight over-estimate of padding.
let approx_pad = BORDER_W * TAIL_HEIGHT + TEXT_PADDING;
let max_inner_half_w = ((MAX_BUBBLE_WIDTH - 2.0 * approx_pad) * 0.5).max(char_w);
let (n_lines, max_line_chars) =
atlas.measure_text(&text, CHAR_WORLD_HEIGHT, max_inner_half_w);
// Corners: tl → tr → br → bl (CCW in clip space when billboard faces camera).
let tl = anchor - right * half_w + up * body_half_h;
let tr = anchor + right * half_w + up * body_half_h;
let br = anchor + right * half_w - up * total_down;
let bl = anchor - right * half_w - up * total_down;
// Compute body height analytically.
// border_world = BORDER_W * body_height (body is the smaller dimension)
// body_height = text_h + 2 * (border_world + TEXT_PADDING)
// -> body_height * (1 - 2*BORDER_W) = text_h + 2*TEXT_PADDING
let text_h = n_lines as f32 * CHAR_WORLD_HEIGHT
+ n_lines.saturating_sub(1) as f32 * LINE_SPACING;
let body_height = (text_h + 2.0 * TEXT_PADDING) / (1.0 - 2.0 * BORDER_W);
let vertices = [
BillboardVertex {
position: tl.to_array(),
uv: [0.0, 0.0],
},
BillboardVertex {
position: tr.to_array(),
uv: [1.0, 0.0],
},
BillboardVertex {
position: br.to_array(),
uv: [1.0, 1.0],
},
BillboardVertex {
position: bl.to_array(),
uv: [0.0, 1.0],
},
];
// Bubble width: fit the longest line exactly, then clamp to [MIN, MAX].
let border_world = BORDER_W * body_height;
let needed_width = max_line_chars as f32 * char_w + 2.0 * (border_world + TEXT_PADDING);
let bubble_width = needed_width.clamp(MIN_BUBBLE_WIDTH, MAX_BUBBLE_WIDTH);
let uniforms = BubbleUniforms {
view_proj: view_proj.to_cols_array_2d(),
size: [BUBBLE_WIDTH, BUBBLE_HEIGHT],
body_frac: BODY_FRAC,
corner_r: CORNER_R,
border_w: BORDER_W,
_pad1: [0.0; 3],
fill_color: FILL_COLOR,
border_color: BORDER_COLOR,
_pad2: [0.0; 32],
};
let bubble_height = body_height + TAIL_HEIGHT;
let body_frac = body_height / bubble_height;
calls.push(BillboardDrawCall { vertices, uniforms });
// Billboard orientation
let anchor = bubble_pos;
let to_camera = camera_pos - anchor;
let forward = if to_camera.length_squared() > 1e-6
{
to_camera.normalize()
}
else
{
Vec3::Z
};
let up_ref = Vec3::Y;
let right = if forward.abs().dot(up_ref) > 0.99
{
Vec3::X
}
else
{
up_ref.cross(forward).normalize()
};
let up = forward.cross(right).normalize();
let half_w = bubble_width * 0.5;
let tl = anchor - right * half_w + up * body_height;
let tr = anchor + right * half_w + up * body_height;
let br = anchor + right * half_w - up * TAIL_HEIGHT;
let bl = anchor - right * half_w - up * TAIL_HEIGHT;
let vertices = [
BillboardVertex {
position: tl.to_array(),
uv: [0.0, 0.0],
},
BillboardVertex {
position: tr.to_array(),
uv: [1.0, 0.0],
},
BillboardVertex {
position: br.to_array(),
uv: [1.0, 1.0],
},
BillboardVertex {
position: bl.to_array(),
uv: [0.0, 1.0],
},
];
let uniforms = BubbleUniforms {
view_proj: view_proj.to_cols_array_2d(),
size: [bubble_width, bubble_height],
body_frac,
corner_r: CORNER_R,
border_w: BORDER_W,
_pad1: [0.0; 3],
fill_color: FILL_COLOR,
border_color: BORDER_COLOR,
_pad2: [0.0; 32],
};
let inner_half_w = bubble_width * 0.5 - border_world - TEXT_PADDING;
let inner_top_y = body_height - border_world - TEXT_PADDING;
let text_verts = atlas.build_bubble_text(
&text,
anchor,
right,
up,
inner_half_w,
inner_top_y,
CHAR_WORLD_HEIGHT,
LINE_SPACING,
);
(BillboardDrawCall { vertices, uniforms }, text_verts)
});
calls.push(draw_call);
all_text.extend(text_verts);
}
calls
(calls, all_text)
}

View File

@@ -0,0 +1,404 @@
use bladeink::story::Story;
use glam::Vec3;
use crate::components::dialog::{
DialogBubbleComponent, DialogOutcomeEvent, DialogPhase, DialogProjectileComponent,
DialogSourceComponent, ParryButton,
};
use crate::components::trigger::{TriggerEvent, TriggerEventKind};
use crate::entity::{EntityHandle, EntityManager};
use crate::utility::transform::Transform;
use crate::world::Storage;
const DEFAULT_DISPLAY_TIME: f32 = 3.0;
const PARRY_TAG_PREFIX: &str = "parry:";
const TIMER_TAG_PREFIX: &str = "timer:";
pub fn dialog_system(
entities: &mut EntityManager,
trigger_events: &[TriggerEvent],
dialog_sources: &Storage<DialogSourceComponent>,
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
transforms: &mut Storage<Transform>,
names: &mut Storage<String>,
player_tags: &Storage<()>,
projectile_tags: &mut Storage<()>,
dialog_projectiles: &mut Storage<DialogProjectileComponent>,
dialog_outcomes: &mut Vec<DialogOutcomeEvent>,
delta: f32,
)
{
process_trigger_events(
entities,
trigger_events,
dialog_sources,
bubble_tags,
dialog_bubbles,
transforms,
names,
projectile_tags,
dialog_projectiles,
);
tick_displaying_bubbles(
entities,
bubble_tags,
dialog_bubbles,
transforms,
player_tags,
projectile_tags,
dialog_projectiles,
delta,
);
process_outcomes(bubble_tags, dialog_bubbles, dialog_outcomes);
}
fn process_trigger_events(
entities: &mut EntityManager,
trigger_events: &[TriggerEvent],
dialog_sources: &Storage<DialogSourceComponent>,
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
transforms: &mut Storage<Transform>,
names: &mut Storage<String>,
projectile_tags: &mut Storage<()>,
dialog_projectiles: &mut Storage<DialogProjectileComponent>,
)
{
let events: Vec<_> = trigger_events.iter().cloned().collect();
for event in events
{
let has_source = dialog_sources.get(event.trigger_entity).is_some();
if !has_source
{
continue;
}
match event.kind
{
TriggerEventKind::Entered =>
{
let already_active = bubble_tags.all().iter().any(|&b| {
dialog_bubbles
.with(b, |db| db.character_entity == event.trigger_entity)
.unwrap_or(false)
});
if already_active
{
continue;
}
spawn_bubble(
entities,
dialog_sources,
bubble_tags,
dialog_bubbles,
transforms,
names,
event.trigger_entity,
);
}
TriggerEventKind::Exited =>
{
despawn_bubbles_for_character(
bubble_tags,
dialog_bubbles,
transforms,
projectile_tags,
dialog_projectiles,
event.trigger_entity,
);
}
}
}
}
fn spawn_bubble(
entities: &mut EntityManager,
dialog_sources: &Storage<DialogSourceComponent>,
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
transforms: &mut Storage<Transform>,
names: &mut Storage<String>,
character_entity: EntityHandle,
)
{
let ink_json = match dialog_sources.with(character_entity, |s| s.ink_json.clone())
{
Some(json) => json,
None => return,
};
let mut story = match Story::new(&ink_json)
{
Ok(s) => s,
Err(e) =>
{
eprintln!("Failed to load ink story: {e}");
return;
}
};
let (text, parry, display_time) = advance_story(&mut story);
let character_pos = transforms
.with(character_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let bubble_entity = entities.spawn();
transforms.insert(
bubble_entity,
Transform::from_position(character_pos + Vec3::new(0.0, 8.0, 0.0)),
);
names.insert(bubble_entity, "DialogBubble".to_string());
bubble_tags.insert(bubble_entity, ());
dialog_bubbles.insert(
bubble_entity,
DialogBubbleComponent {
story,
character_entity,
current_text: text,
phase: DialogPhase::Displaying {
timer: display_time,
},
correct_parry: parry,
display_time,
},
);
}
fn despawn_bubbles_for_character(
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
transforms: &mut Storage<Transform>,
projectile_tags: &mut Storage<()>,
dialog_projectiles: &mut Storage<DialogProjectileComponent>,
character_entity: EntityHandle,
)
{
let bubbles: Vec<EntityHandle> = bubble_tags.all();
for bubble_entity in bubbles
{
let matches = dialog_bubbles
.with(bubble_entity, |b| b.character_entity == character_entity)
.unwrap_or(false);
if !matches
{
continue;
}
if let Some(bubble) = dialog_bubbles.get(bubble_entity)
{
if let DialogPhase::ProjectileInFlight { projectile_entity } = bubble.phase
{
transforms.remove(projectile_entity);
projectile_tags.remove(projectile_entity);
dialog_projectiles.remove(projectile_entity);
}
}
transforms.remove(bubble_entity);
bubble_tags.remove(bubble_entity);
dialog_bubbles.remove(bubble_entity);
}
}
fn tick_displaying_bubbles(
entities: &mut EntityManager,
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
transforms: &mut Storage<Transform>,
player_tags: &Storage<()>,
projectile_tags: &mut Storage<()>,
dialog_projectiles: &mut Storage<DialogProjectileComponent>,
delta: f32,
)
{
let bubbles: Vec<EntityHandle> = bubble_tags.all();
for bubble_entity in bubbles
{
let expired = dialog_bubbles
.with_mut(bubble_entity, |b| {
if let DialogPhase::Displaying { ref mut timer } = b.phase
{
*timer -= delta;
*timer <= 0.0
}
else
{
false
}
})
.unwrap_or(false);
if expired
{
let correct_parry = match dialog_bubbles.with(bubble_entity, |b| b.correct_parry)
{
Some(Some(p)) => p,
_ =>
{
transforms.remove(bubble_entity);
bubble_tags.remove(bubble_entity);
dialog_bubbles.remove(bubble_entity);
continue;
}
};
let bubble_pos = transforms
.with(bubble_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let player_entity = player_tags
.all()
.into_iter()
.next()
.expect("no player entity");
let player_pos = transforms
.with(player_entity, |t| t.position)
.unwrap_or(Vec3::ZERO);
let velocity = player_pos - bubble_pos;
let projectile_entity = entities.spawn();
transforms.insert(projectile_entity, Transform::from_position(bubble_pos));
projectile_tags.insert(projectile_entity, ());
dialog_projectiles.insert(
projectile_entity,
DialogProjectileComponent {
bubble_entity,
correct_parry,
parry_window_open: false,
velocity,
},
);
dialog_bubbles.with_mut(bubble_entity, |b| {
b.phase = DialogPhase::ProjectileInFlight { projectile_entity };
});
}
}
}
fn process_outcomes(
bubble_tags: &mut Storage<()>,
dialog_bubbles: &mut Storage<DialogBubbleComponent>,
dialog_outcomes: &mut Vec<DialogOutcomeEvent>,
)
{
let outcomes: Vec<_> = dialog_outcomes.drain(..).collect();
for event in outcomes
{
let bubble_entity = event.bubble_entity;
if dialog_bubbles.get(bubble_entity).is_none()
{
continue;
}
let choice_tag = event.outcome.to_choice_tag();
let next = dialog_bubbles.with_mut(bubble_entity, |b| {
let choices = b.story.get_current_choices();
let idx = choices
.iter()
.position(|c| c.tags.iter().any(|t| t.trim() == choice_tag));
if let Some(idx) = idx
{
let _ = b.story.choose_choice_index(idx);
}
else
{
println!("No choice found for outcome tag '{choice_tag}', using first available");
if !choices.is_empty()
{
let _ = b.story.choose_choice_index(0);
}
}
if b.story.can_continue()
{
let (text, parry, display_time) = advance_story(&mut b.story);
Some((text, parry, display_time))
}
else
{
None
}
});
match next
{
Some(Some((text, parry, display_time))) =>
{
dialog_bubbles.with_mut(bubble_entity, |b| {
b.current_text = text;
b.correct_parry = parry;
b.display_time = display_time;
b.phase = DialogPhase::Displaying {
timer: display_time,
};
});
}
Some(None) =>
{
bubble_tags.remove(bubble_entity);
dialog_bubbles.remove(bubble_entity);
}
None =>
{}
}
}
}
fn advance_story(story: &mut Story) -> (String, Option<ParryButton>, f32)
{
let mut full_text = String::new();
let mut parry: Option<ParryButton> = None;
let mut display_time = DEFAULT_DISPLAY_TIME;
while story.can_continue()
{
match story.cont()
{
Ok(line) => full_text.push_str(&line),
Err(e) =>
{
eprintln!("Story error: {e}");
break;
}
}
if let Ok(tags) = story.get_current_tags()
{
for tag in tags
{
let tag = tag.trim().to_string();
let tag = tag.as_str();
if let Some(val) = tag.strip_prefix(PARRY_TAG_PREFIX)
{
parry = ParryButton::from_tag(val.trim());
}
else if let Some(val) = tag.strip_prefix(TIMER_TAG_PREFIX)
{
if let Ok(t) = val.trim().parse::<f32>()
{
display_time = t;
}
}
}
}
}
(full_text.trim().to_string(), parry, display_time)
}

View File

@@ -1,15 +1,17 @@
use crate::world::World;
use crate::components::FollowComponent;
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn follow_system(world: &mut World)
pub fn follow_system(follows: &mut Storage<FollowComponent>, transforms: &mut Storage<Transform>)
{
let following_entities: Vec<_> = world.follows.all();
let following_entities: Vec<_> = follows.all();
for entity in following_entities
{
if let Some(follow) = world.follows.get(entity)
if let Some(follow) = follows.get(entity)
{
let target = follow.target;
if let Some(target_transform) = world.transforms.get(target)
if let Some(target_transform) = transforms.get(target)
{
let target_pos = target_transform.position;
let target_rot = target_transform.rotation;
@@ -18,7 +20,7 @@ pub fn follow_system(world: &mut World)
let inherit_rot = follow.inherit_rotation;
let inherit_scale = follow.inherit_scale;
world.transforms.with_mut(entity, |transform| {
transforms.with_mut(entity, |transform| {
transform.position = target_pos;
if inherit_rot

View File

@@ -1,24 +1,45 @@
use glam::Vec3;
use crate::components::camera::CameraComponent;
use crate::components::FollowComponent;
use crate::components::InputComponent;
use crate::utility::input::InputState;
use crate::world::World;
use crate::world::Storage;
pub fn player_input_system(world: &mut World, input_state: &InputState)
pub fn player_input_system(
cameras: &Storage<CameraComponent>,
follows: &Storage<FollowComponent>,
player_tags: &Storage<()>,
inputs: &mut Storage<InputComponent>,
input_state: &InputState,
)
{
let Some((_, camera)) = world.active_camera()
else
let camera_is_following = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.map(|(e, _)| follows.get(*e).is_some())
.unwrap_or(false);
if !camera_is_following
{
return;
}
let (_, camera) = match cameras.components.iter().find(|(_, cam)| cam.is_active)
{
Some((e, c)) => (*e, c),
None => return,
};
let forward = camera.get_forward_horizontal();
let right = camera.get_right_horizontal();
let players = world.player_tags.all();
let players = player_tags.all();
for player in players
{
world.inputs.with_mut(player, |input_component| {
inputs.with_mut(player, |input_component| {
let mut local_input = Vec3::ZERO;
if input_state.w

View File

@@ -1,12 +1,12 @@
pub mod camera;
pub mod dialog;
pub mod dialog_camera;
pub mod dialog_projectile;
pub mod dialog_render;
pub mod dialog_system;
pub mod follow;
pub mod input;
pub mod particle;
pub mod physics_sync;
pub mod player_states;
pub mod render;
pub mod rotate;
pub mod snow;
@@ -16,14 +16,17 @@ pub mod tree_dissolve;
pub mod trigger;
pub use camera::{
camera_follow_system, camera_input_system, camera_noclip_system, camera_view_matrix,
start_camera_following,
camera_follow_system, camera_ground_clamp_system, camera_input_system, camera_intent_system,
camera_noclip_system, camera_transition_system, camera_view_matrix,
};
pub use dialog::dialog_system;
pub use dialog_camera::dialog_camera_system;
pub use dialog_camera::{dialog_camera_system, dialog_camera_transition_system};
pub use dialog_projectile::dialog_projectile_system;
pub use dialog_render::dialog_bubble_render_system;
pub use dialog_system::dialog_system;
pub use input::player_input_system;
pub use particle::{
collect_instances, particle_intent_system, particle_update_system, spawn_snow_particles,
};
pub use physics_sync::physics_sync_system;
pub use render::render_system;
pub use rotate::rotate_system;

198
src/systems/particle.rs Normal file
View File

@@ -0,0 +1,198 @@
use rand::Rng;
use crate::components::particle::{ParticleEmitterConfig, SpawnParticleIntent};
use crate::render::particle_types::ParticleInstanceRaw;
pub struct Particle
{
pub position: glam::Vec3,
pub velocity: glam::Vec3,
pub age: f32,
pub lifetime: f32,
pub size: f32,
pub gravity: f32,
pub color_start: [f32; 4],
pub color_end: [f32; 4],
}
pub struct ParticleBuffers
{
pub particles: Vec<Particle>,
pub instances: Vec<ParticleInstanceRaw>,
pub emit_accumulator: f32,
}
fn random_unit_vec(rng: &mut impl Rng) -> glam::Vec3
{
loop
{
let v = glam::Vec3::new(
rng.random_range(-1.0_f32..1.0),
rng.random_range(-1.0_f32..1.0),
rng.random_range(-1.0_f32..1.0),
);
let len_sq = v.length_squared();
if len_sq <= 1.0 && len_sq > 0.0001
{
return v / len_sq.sqrt();
}
}
}
fn random_velocity(rng: &mut impl Rng, config: &ParticleEmitterConfig) -> glam::Vec3
{
let speed = rng.random_range(config.speed.start..config.speed.end);
let dir = if let Some(d) = config.direction
{
let perp = if d.x.abs() < 0.9
{
glam::Vec3::X
}
else
{
glam::Vec3::Y
};
let right = d.cross(perp).normalize();
let up = d.cross(right);
let angle = rng.random_range(0.0_f32..config.direction_spread);
let azimuth = rng.random_range(0.0_f32..(2.0 * std::f32::consts::PI));
(d * angle.cos() + right * angle.sin() * azimuth.cos() + up * angle.sin() * azimuth.sin())
.normalize()
}
else
{
random_unit_vec(rng)
};
dir * speed
}
fn spawn_from_config(
buffers: &mut ParticleBuffers,
origin: glam::Vec3,
config: &ParticleEmitterConfig,
rng: &mut impl Rng,
)
{
for _ in 0..config.burst_count
{
let lifetime = rng.random_range(config.lifetime.start..config.lifetime.end);
let size = rng.random_range(config.size.start..config.size.end);
let velocity = random_velocity(rng, config);
buffers.particles.push(Particle {
position: origin,
velocity,
age: 0.0,
lifetime,
size,
gravity: config.gravity,
color_start: config.color_start,
color_end: config.color_end,
});
}
}
pub fn particle_intent_system(
particle_buffers: &mut Option<ParticleBuffers>,
spawn_particle_intents: &mut Vec<SpawnParticleIntent>,
)
{
if particle_buffers.is_none()
{
*particle_buffers = Some(ParticleBuffers {
particles: Vec::new(),
instances: Vec::new(),
emit_accumulator: 0.0,
});
}
let intents: Vec<SpawnParticleIntent> = spawn_particle_intents.drain(..).collect();
if intents.is_empty()
{
return;
}
let buffers = particle_buffers.as_mut().unwrap();
let mut rng = rand::rng();
for intent in intents
{
spawn_from_config(buffers, intent.origin, &intent.config, &mut rng);
}
}
pub fn particle_update_system(particle_buffers: &mut Option<ParticleBuffers>, delta: f32)
{
let Some(ref mut buffers) = particle_buffers
else
{
return;
};
for particle in &mut buffers.particles
{
particle.velocity.y -= particle.gravity * delta;
particle.position += particle.velocity * delta;
particle.age += delta / particle.lifetime;
}
buffers.particles.retain(|p| p.age < 1.0);
}
pub fn spawn_snow_particles(buffers: &mut ParticleBuffers, camera_pos: glam::Vec3, delta: f32)
{
let mut rng = rand::rng();
let rate = 200.0_f32;
buffers.emit_accumulator += delta;
let to_emit = (buffers.emit_accumulator * rate) as u32;
buffers.emit_accumulator -= to_emit as f32 / rate;
for _ in 0..to_emit
{
let x = camera_pos.x + rng.random_range(-20.0_f32..20.0_f32);
let z = camera_pos.z + rng.random_range(-20.0_f32..20.0_f32);
let y = rng.random_range((camera_pos.y + 8.0)..(camera_pos.y + 20.0));
let vx = rng.random_range(-0.3_f32..0.3_f32);
let vy = rng.random_range(-2.0_f32..-0.5_f32);
let vz = rng.random_range(-0.3_f32..0.3_f32);
let size = rng.random_range(0.05_f32..0.15_f32);
let lifetime = rng.random_range(2.0_f32..5.0_f32);
buffers.particles.push(Particle {
position: glam::Vec3::new(x, y, z),
velocity: glam::Vec3::new(vx, vy, vz),
age: 0.0,
lifetime,
size,
gravity: 0.0,
color_start: [1.0, 1.0, 1.0, 1.0],
color_end: [1.0, 1.0, 1.0, 0.0],
});
}
buffers
.particles
.retain(|p| p.position.y >= camera_pos.y - 5.0);
}
pub fn collect_instances(buffers: &mut ParticleBuffers) -> &[ParticleInstanceRaw]
{
buffers.instances.clear();
for particle in &buffers.particles
{
let t = particle.age;
let color = [
particle.color_start[0] + (particle.color_end[0] - particle.color_start[0]) * t,
particle.color_start[1] + (particle.color_end[1] - particle.color_start[1]) * t,
particle.color_start[2] + (particle.color_end[2] - particle.color_start[2]) * t,
particle.color_start[3] + (particle.color_end[3] - particle.color_start[3]) * t,
];
buffers.instances.push(ParticleInstanceRaw {
position: particle.position.into(),
velocity: particle.velocity.into(),
size: particle.size,
color,
age: t,
_padding: [0.0; 3],
});
}
&buffers.instances
}

View File

@@ -1,21 +1,27 @@
use crate::components::PhysicsComponent;
use crate::entity::EntityManager;
use crate::physics::PhysicsManager;
use crate::utility::transform::Transform;
use crate::world::World;
use crate::world::Storage;
pub fn physics_sync_system(world: &mut World)
pub fn physics_sync_system(
entities: &EntityManager,
physics: &Storage<PhysicsComponent>,
transforms: &mut Storage<Transform>,
)
{
let all_entities = world.entities.all_entities();
let all_entities = entities.all_entities();
for entity in all_entities
{
if let Some(physics) = world.physics.get(entity)
if let Some(physics) = physics.get(entity)
{
if let Some(rigidbody_position) =
PhysicsManager::get_rigidbody_position(physics.rigidbody)
{
let transform = Transform::from(rigidbody_position);
world.transforms.with_mut(entity, |t| {
transforms.with_mut(entity, |t| {
*t = transform;
});
}

View File

@@ -1,17 +1,25 @@
use crate::components::{DissolveComponent, MeshComponent};
use crate::entity::EntityManager;
use crate::loaders::mesh::InstanceRaw;
use crate::render::DrawCall;
use crate::world::World;
use crate::utility::transform::Transform;
use crate::world::Storage;
use bytemuck::cast_slice;
pub fn render_system(world: &World) -> Vec<DrawCall>
pub fn render_system(
entities: &EntityManager,
transforms: &Storage<Transform>,
meshes: &Storage<MeshComponent>,
dissolves: &Storage<DissolveComponent>,
) -> Vec<DrawCall>
{
let all_entities = world.entities.all_entities();
let all_entities = entities.all_entities();
all_entities
.iter()
.filter_map(|&entity| {
let transform = world.transforms.get(entity)?;
let mesh_component = world.meshes.get(entity)?;
let transform = transforms.get(entity)?;
let mesh_component = meshes.get(entity)?;
let model_matrix = transform.to_matrix();
@@ -22,7 +30,7 @@ pub fn render_system(world: &World) -> Vec<DrawCall>
}
else
{
let dissolve_amount = world.dissolves.get(entity).map(|d| d.amount).unwrap_or(0.0);
let dissolve_amount = dissolves.get(entity).map(|d| d.amount).unwrap_or(0.0);
let instance_data = InstanceRaw {
model: model_matrix.to_cols_array_2d(),

View File

@@ -1,18 +1,24 @@
use glam::Quat;
use crate::world::World;
use crate::components::RotateComponent;
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn rotate_system(world: &mut World, delta: f32)
pub fn rotate_system(
rotates: &Storage<RotateComponent>,
transforms: &mut Storage<Transform>,
delta: f32,
)
{
let entities = world.rotates.all();
let entities = rotates.all();
for entity in entities
{
if let Some(rotate) = world.rotates.get(entity)
if let Some(rotate) = rotates.get(entity)
{
let rotation_delta = Quat::from_axis_angle(rotate.axis, rotate.speed * delta);
world.transforms.with_mut(entity, |transform| {
transforms.with_mut(entity, |transform| {
transform.rotation = rotation_delta * transform.rotation;
});
}

View File

@@ -1,13 +1,45 @@
use crate::snow::SnowLayer;
use crate::world::World;
use crate::components::camera::CameraComponent;
use crate::components::FollowComponent;
use crate::render::snow::SnowLayer;
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn snow_system(world: &World, snow_layer: &mut SnowLayer, noclip: bool)
pub fn snow_system(
cameras: &Storage<CameraComponent>,
transforms: &Storage<Transform>,
player_tags: &Storage<()>,
follows: &Storage<FollowComponent>,
snow_layer: &mut Option<SnowLayer>,
)
{
let camera_pos = world.active_camera_position();
let player_pos = world.player_position();
if !noclip
let camera_pos = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.and_then(|(e, _)| transforms.get(*e))
.map(|t| t.position)
.unwrap_or(glam::Vec3::ZERO);
let player_pos = player_tags
.all()
.first()
.and_then(|e| transforms.get(*e))
.map(|t| t.position)
.unwrap_or(glam::Vec3::ZERO);
let is_following = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.map(|(e, _)| follows.get(*e).is_some())
.unwrap_or(false);
if let Some(ref mut snow_layer) = snow_layer
{
snow_layer.deform_at_position(player_pos, 1.5, 10.0);
if is_following
{
snow_layer.deform_at_position(player_pos, 1.5, 10.0);
}
snow_layer.update(camera_pos);
}
snow_layer.update(camera_pos);
}

View File

@@ -1,23 +1,28 @@
use crate::components::lights::spot::SpotlightComponent;
use crate::render::Spotlight;
use crate::world::World;
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn spotlight_sync_system(world: &World) -> Vec<Spotlight>
pub fn spotlight_sync_system(
spotlights: &Storage<SpotlightComponent>,
transforms: &Storage<Transform>,
) -> Vec<Spotlight>
{
let mut entities = world.spotlights.all();
let mut entities = spotlights.all();
entities.sort();
let mut spotlights = Vec::new();
let mut result = Vec::new();
for entity in entities
{
if let Some(spotlight_component) = world.spotlights.get(entity)
if let Some(spotlight_component) = spotlights.get(entity)
{
if let Some(transform) = world.transforms.get(entity)
if let Some(transform) = transforms.get(entity)
{
let position = transform.position + spotlight_component.offset;
let direction = transform.rotation * spotlight_component.direction;
spotlights.push(Spotlight::new(
result.push(Spotlight::new(
position,
direction,
spotlight_component.inner_angle,
@@ -28,5 +33,5 @@ pub fn spotlight_sync_system(world: &World) -> Vec<Spotlight>
}
}
spotlights
result
}

View File

@@ -1,12 +1,15 @@
use crate::components::camera::CameraComponent;
use crate::components::tree_instances::TreeInstancesComponent;
use crate::loaders::mesh::InstanceRaw;
use crate::world::World;
use crate::utility::transform::Transform;
use crate::world::Storage;
use bytemuck::cast_slice;
pub fn tree_dissolve_update_system(world: &mut World, delta: f32)
pub fn tree_dissolve_update_system(tree_instances: &mut Storage<TreeInstancesComponent>, delta: f32)
{
for entity in world.tree_instances.all()
for entity in tree_instances.all()
{
if let Some(tree_instances) = world.tree_instances.get_mut(entity)
if let Some(tree_instances) = tree_instances.get_mut(entity)
{
for i in 0..tree_instances.dissolve_amounts.len()
{
@@ -20,15 +23,24 @@ pub fn tree_dissolve_update_system(world: &mut World, delta: f32)
}
}
pub fn tree_occlusion_system(world: &mut World)
pub fn tree_occlusion_system(
player_tags: &Storage<()>,
transforms: &Storage<Transform>,
cameras: &Storage<CameraComponent>,
tree_instances: &mut Storage<TreeInstancesComponent>,
)
{
let player_entity = world.player_tags.all().first().copied();
let player_pos = player_entity.and_then(|e| world.transforms.get(e).map(|t| t.position));
let player_entity = player_tags.all().first().copied();
let player_pos = player_entity.and_then(|e| transforms.get(e).map(|t| t.position));
if let Some(player_pos) = player_pos
{
let camera_entity = world.active_camera().map(|(e, _)| e);
let camera_pos = camera_entity.and_then(|e| world.transforms.get(e).map(|t| t.position));
let camera_entity = cameras
.components
.iter()
.find(|(_, cam)| cam.is_active)
.map(|(e, _)| *e);
let camera_pos = camera_entity.and_then(|e| transforms.get(e).map(|t| t.position));
if let Some(camera_pos) = camera_pos
{
@@ -43,9 +55,9 @@ pub fn tree_occlusion_system(world: &mut World)
let to_player_normalized = to_player.normalize();
let occlusion_radius = 10.0;
for tree_entity in world.tree_instances.all()
for tree_entity in tree_instances.all()
{
if let Some(tree_instances) = world.tree_instances.get_mut(tree_entity)
if let Some(tree_instances) = tree_instances.get_mut(tree_entity)
{
for (idx, instance) in tree_instances.instances.iter().enumerate()
{
@@ -81,11 +93,11 @@ pub fn tree_occlusion_system(world: &mut World)
}
}
pub fn tree_instance_buffer_update_system(world: &mut World)
pub fn tree_instance_buffer_update_system(tree_instances: &Storage<TreeInstancesComponent>)
{
for entity in world.tree_instances.all()
for entity in tree_instances.all()
{
if let Some(tree_instances) = world.tree_instances.get(entity)
if let Some(tree_instances) = tree_instances.get(entity)
{
let instance_data_vec: Vec<InstanceRaw> = tree_instances
.instances

View File

@@ -1,41 +1,47 @@
use glam::Vec3;
use crate::components::trigger::{
TriggerEvent, TriggerEventKind, TriggerFilter, TriggerShape, TriggerState,
TriggerComponent, TriggerEvent, TriggerEventKind, TriggerFilter, TriggerShape, TriggerState,
};
use crate::entity::EntityHandle;
use crate::world::World;
use crate::utility::transform::Transform;
use crate::world::Storage;
pub fn trigger_system(world: &mut World)
pub fn trigger_system(
trigger_events: &mut Vec<TriggerEvent>,
triggers: &mut Storage<TriggerComponent>,
transforms: &Storage<Transform>,
player_tags: &Storage<()>,
)
{
world.trigger_events.clear();
trigger_events.clear();
let trigger_entities: Vec<EntityHandle> = world.triggers.all();
let trigger_entities: Vec<EntityHandle> = triggers.all();
let mut pending_events: Vec<TriggerEvent> = Vec::new();
for trigger_entity in trigger_entities
{
let trigger_pos = match world.transforms.get(trigger_entity)
let trigger_pos = match transforms.get(trigger_entity)
{
Some(t) => t.position,
None => continue,
};
let candidate_entities: Vec<EntityHandle> = match world.triggers.get(trigger_entity)
let candidate_entities: Vec<EntityHandle> = match triggers.get(trigger_entity)
{
Some(trigger) => match &trigger.filter
{
TriggerFilter::Player => world.player_tags.all(),
TriggerFilter::Player => player_tags.all(),
},
None => continue,
};
let activator_positions: Vec<(EntityHandle, Vec3)> = candidate_entities
.into_iter()
.filter_map(|e| world.transforms.get(e).map(|t| (e, t.position)))
.filter_map(|e| transforms.get(e).map(|t| (e, t.position)))
.collect();
let overlapping = match world.triggers.get(trigger_entity)
let overlapping = match triggers.get(trigger_entity)
{
Some(trigger) => activator_positions
.iter()
@@ -48,7 +54,7 @@ pub fn trigger_system(world: &mut World)
let first_activator = activator_positions.first().map(|(e, _)| *e);
if let Some(trigger) = world.triggers.get_mut(trigger_entity)
if let Some(trigger) = triggers.get_mut(trigger_entity)
{
match (&trigger.state, overlapping)
{
@@ -82,5 +88,5 @@ pub fn trigger_system(world: &mut World)
}
}
world.trigger_events.extend(pending_events);
trigger_events.extend(pending_events);
}

View File

@@ -5,18 +5,24 @@ use crate::components::dialog::{
};
use crate::components::dissolve::DissolveComponent;
use crate::components::follow::FollowComponent;
use crate::components::intent::{CameraTransitionIntent, FollowPlayerIntent, StopFollowingIntent};
use crate::components::lights::spot::SpotlightComponent;
use crate::components::particle::SpawnParticleIntent;
use crate::components::player_states::{
FallingState, IdleState, JumpingState, LeapingState, RollingState, WalkingState,
};
use crate::components::tree_instances::TreeInstancesComponent;
use crate::components::trigger::{TriggerComponent, TriggerEvent};
use crate::components::{
CameraComponent, InputComponent, JumpComponent, MeshComponent, MovementComponent,
PhysicsComponent, RotateComponent,
CameraComponent, CameraTransition, InputComponent, JumpComponent, MeshComponent,
MovementComponent, PhysicsComponent, RotateComponent,
};
use crate::debug::DebugMode;
use crate::entity::{EntityHandle, EntityManager};
use crate::state::StateMachine;
use crate::loaders::mesh::Mesh;
use crate::render::snow::SnowLayer;
use crate::states::state::StateMachine;
use crate::systems::particle::ParticleBuffers;
pub use crate::utility::transform::Transform;
@@ -92,6 +98,7 @@ pub struct World
pub leaping_states: Storage<LeapingState>,
pub rolling_states: Storage<RollingState>,
pub cameras: Storage<CameraComponent>,
pub camera_transitions: Storage<CameraTransition>,
pub spotlights: Storage<SpotlightComponent>,
pub tree_tags: Storage<()>,
pub dissolves: Storage<DissolveComponent>,
@@ -107,6 +114,20 @@ pub struct World
pub bubble_tags: Storage<()>,
pub projectile_tags: Storage<()>,
pub dialog_outcomes: Vec<DialogOutcomeEvent>,
pub spawn_particle_intents: Vec<SpawnParticleIntent>,
pub particle_buffers: Option<ParticleBuffers>,
// --- intents (one-frame, consumed after processing) ---
pub follow_player_intents: Storage<FollowPlayerIntent>,
pub stop_following_intents: Storage<StopFollowingIntent>,
pub camera_transition_intents: Storage<CameraTransitionIntent>,
// --- singleton state (not per-entity) ---
pub snow_layer: Option<SnowLayer>,
pub debug_mode: DebugMode,
pub was_dialog_active: bool,
pub gizmo_mesh: Option<Mesh>,
pub gizmo_instance_buffer: Option<wgpu::Buffer>,
}
impl World
@@ -130,6 +151,7 @@ impl World
leaping_states: Storage::new(),
rolling_states: Storage::new(),
cameras: Storage::new(),
camera_transitions: Storage::new(),
spotlights: Storage::new(),
tree_tags: Storage::new(),
dissolves: Storage::new(),
@@ -145,6 +167,16 @@ impl World
bubble_tags: Storage::new(),
projectile_tags: Storage::new(),
dialog_outcomes: Vec::new(),
spawn_particle_intents: Vec::new(),
particle_buffers: None,
follow_player_intents: Storage::new(),
stop_following_intents: Storage::new(),
camera_transition_intents: Storage::new(),
snow_layer: None,
debug_mode: DebugMode::default(),
was_dialog_active: false,
gizmo_mesh: None,
gizmo_instance_buffer: None,
}
}
@@ -170,6 +202,7 @@ impl World
self.leaping_states.remove(entity);
self.rolling_states.remove(entity);
self.cameras.remove(entity);
self.camera_transitions.remove(entity);
self.spotlights.remove(entity);
self.tree_tags.remove(entity);
self.dissolves.remove(entity);
@@ -183,6 +216,9 @@ impl World
self.dialog_projectiles.remove(entity);
self.bubble_tags.remove(entity);
self.projectile_tags.remove(entity);
self.follow_player_intents.remove(entity);
self.stop_following_intents.remove(entity);
self.camera_transition_intents.remove(entity);
self.entities.despawn(entity);
}
@@ -203,6 +239,13 @@ impl World
.unwrap_or(glam::Vec3::ZERO)
}
pub fn camera_is_following(&self) -> bool
{
self.active_camera()
.map(|(e, _)| self.follows.get(e).is_some())
.unwrap_or(false)
}
pub fn player_position(&self) -> glam::Vec3
{
self.player_tags