diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/BLENDER_SNOW_WORKFLOW.md b/BLENDER_SNOW_WORKFLOW.md new file mode 100644 index 0000000..9e53dd4 --- /dev/null +++ b/BLENDER_SNOW_WORKFLOW.md @@ -0,0 +1,1027 @@ +# Blender 5.0 Snow Layer Workflow + +## Overview + +This document describes the **Blender 5.0** Geometry Nodes setup for generating a procedural snow layer that: +- Only accumulates on upward-facing surfaces (slope-based filtering) +- Respects overhangs and occlusion (raycast-based filtering) +- Exports as a heightmap for runtime deformation in the game + +**Requirements:** +- Blender 5.0 or later +- Terrain mesh with proper UVs +- Scene objects for occlusion testing (optional) + +## Export Requirements + +**Output Files:** +1. `textures/snow_depth.exr` - Single-channel R32Float heightmap (snow depth in world units, 0-0.5m range) + - Generated by `blender/scripts/generate_snow_depth.py` + - Or manually exported following steps below +2. `meshes/snow_mesh.gltf` - Visual snow mesh for verification (optional, not used in game) + +**Resolution:** Match terrain heightmap resolution (e.g., 512×512 or 1024×1024) + +**Alignment:** Snow depth map must align with terrain UVs (same coordinate space) + +**File Format:** +- Format: OpenEXR (.exr) +- Bit Depth: 32-bit Float (not 16-bit Half!) +- Compression: ZIP (lossless) +- Color Space: Linear +- Expected size: ~200-500KB for 512×512 with ZIP compression + +## Geometry Nodes Setup (Blender 5.0) + +### Creating the Modifier + +1. Select your terrain mesh +2. Go to **Modifiers** panel +3. Add → **Geometry Nodes** modifier +4. Click **New** to create a new node tree +5. Name it: `Snow Accumulation` + +### Node Graph Overview + +``` +[Group Input] + │ + ├─→ [Position] ──→ [Raycast to Geometry] ──→ [Is Hit] ──→ [Boolean Math (NOT)] ──→ [Occlusion Factor] + │ ↑ │ + │ └─ [Object Info] ← Scene Collection │ + │ │ + ├─→ [Normal] ──→ [Vector Math (Dot)] ──→ [Map Range] ──→ [Normal Factor] │ + │ ↑ │ │ + │ └─ [Combine XYZ(0,0,1)] │ │ + │ │ │ + └─→ [Math (Multiply)] ←─ [Normal Factor] ←─────────────────────┴──────────────────────┘ + │ (Snow Mask) + ├─→ [Math (Multiply by 0.3)] ──→ [Snow Depth Value] + │ │ + │ ├─→ [Store Named Attribute: "snow_depth"] + │ │ + │ └─→ [Vector Math (Multiply)] → [Offset Vector] + │ ↑ + │ └─ [Combine XYZ(0,0,1)] (Up) + │ + └─→ [Set Position] (offset by snow_depth in Z) + │ + └─→ [Group Output] +``` + +### Step-by-Step Setup in Blender 5.0 + +#### 1. Normal-Based Filtering (Slope Detection) + +**Goal:** Snow only on surfaces with normals pointing upward (< 45° from vertical) + +**Add Nodes (Add Menu → Search):** + +1. **Normal** node (Input → Normal) + - Outputs the surface normal vector at each vertex + +2. **Combine XYZ** node (Utilities → Combine XYZ) + - X: `0.0` + - Y: `0.0` + - Z: `1.0` + - This creates the "up" vector (world Z-axis) + +3. **Vector Math** node (Utilities → Vector Math) + - Operation: **Dot Product** + - Connect: Normal → Vector A + - Connect: Combine XYZ → Vector B + - Result: How much the surface faces upward (-1 to 1) + +4. **Map Range** node (Utilities → Map Range) + - Data Type: **Float** + - Clamp: **Enabled** ✓ + - From Min: `0.7` (cos(45°) - surfaces steeper than 45° get no snow) + - From Max: `1.0` (completely flat horizontal surfaces) + - To Min: `0.0` + - To Max: `1.0` + - Connect: Vector Math (Value) → Value + +**Output:** **Normal Factor** (0 = too steep for snow, 1 = perfect for snow accumulation) + +#### 2. Occlusion-Based Filtering (Overhang Detection) + +**Goal:** Snow doesn't accumulate under overhangs or sheltered areas + +**IMPORTANT - Blender 5.0 Working Solution:** + +The key issue: `Object Info` doesn't work with collections, and `Collection Info` outputs instances that raycast ignores. The solution is to use **Collection Info + Realize Instances**, and handle tree instances from other geometry nodes via the modifier stack. + +**Setting Up Scene for Occlusion:** + +1. In **Outliner**, create collection: `Snow Blockers` +2. Add static blocking objects (rocks, cliffs, buildings) +3. **Do NOT** put instanced trees here - we'll handle those separately +4. Keep terrain mesh OUT of this collection + +**Option A: Using Collection (For Static Objects)** + +**Add Nodes:** + +1. **Collection Info** node (Input → Collection Info) + - Collection: Select `Snow Blockers` + - Separate Children: **Disabled** + - Reset Children: **Disabled** + - Transform Space: **Original** + +2. **Realize Instances** node (Instances → Realize Instances) + - Connect: Collection Info (Instances) → Geometry + - **CRITICAL:** This converts instances to real geometry that raycast can see + - Without this, raycast sees nothing! + +3. **Join Geometry** node (Geometry → Join Geometry) + - We'll use this to combine multiple blocker sources + - Connect: Realize Instances → Geometry (socket 0) + - Leave other sockets for additional geometry (trees, etc.) + +**Option B: Including Instanced Trees from Other Geometry Nodes** + +**For trees created by another Geometry Nodes modifier:** + +1. On your **terrain mesh**, ensure the tree-spawning modifier comes **BEFORE** the snow modifier in the stack +2. In the **snow Geometry Nodes tree**, use the **Group Input** geometry + - The input geometry includes all previous modifiers' results + - This includes your instanced trees! + +3. **Geometry Proximity** node (Geometry → Geometry Proximity) - *Alternative approach* + - Target: Connect Group Input → Geometry (this includes tree instances) + - Source Position: Connect Position + - This gives distance to nearest geometry + - Use threshold to determine occlusion + +**Recommended: Combined Raycast Approach** + +**Complete node setup:** + +1. **Group Input** → Fork this into two paths: + - Path A: Your terrain mesh (for snow calculation) + - Path B: Extract instances for raycast targets + +2. **Realize Instances** node + - Connect: Group Input → Geometry + - This realizes ALL instances including trees from previous modifiers + - Output becomes our raycast target + +3. **Collection Info** node (if using additional blockers) + - Collection: `Snow Blockers` + +4. **Realize Instances** node (for collection) + - Connect: Collection Info (Instances) → Geometry + +5. **Join Geometry** node + - Socket 0: Realized instances from Group Input + - Socket 1: Realized instances from Collection + - Output: Combined blocking geometry + +6. **Position** node (Input → Position) + - Source position for rays + +7. **Combine XYZ** node (if not already created) + - X: `0.0`, Y: `0.0`, Z: `1.0` + - Ray direction (up) + +8. **Raycast to Geometry** node + - Target Geometry: Connect Join Geometry → Geometry + - Source Position: Connect Position + - Ray Direction: Connect Combine XYZ (0,0,1) + - Ray Length: `100.0` + - **Output: Is Hit** (true = blocked, false = exposed) + +9. **Boolean Math** node (Utilities → Boolean Math) + - Operation: **NOT** + - Connect: Raycast to Geometry (Is Hit) → Boolean + - **Output: Occlusion Factor** (1.0 = exposed to sky, 0.0 = blocked) + +**CRITICAL DEBUGGING TIPS:** + +**Problem: Raycast doesn't detect anything** +- Check that Realize Instances is connected and enabled +- Verify Join Geometry has inputs on multiple sockets +- Test with a simple cube in `Snow Blockers` collection +- Set Ray Length to large value (100+ for testing) + +**Problem: Trees don't block snow** +- Ensure tree geometry nodes modifier is ABOVE snow modifier in stack +- Add debug: Set Material node after Realize Instances to verify geometry exists +- Check tree instances are actually generating geometry (not empty) + +**Problem: Terrain blocks its own snow** +- Don't include terrain in raycast target +- Use separate Group Input for terrain vs. blockers +- Filter by collection membership + +**Simplified Alternative (No Occlusion):** + +If raycast continues to fail, temporarily skip occlusion: +1. **Value** node (Input → Value) + - Value: `1.0` + - Use this as Occlusion Factor (always exposed) +2. Focus on getting normal-based snow working first +3. Debug raycast separately with simpler geometry + +#### 3. Combine Factors + +**Goal:** Multiply normal factor × occlusion factor to get final snow mask + +**Add Nodes:** + +1. **Math** node (Utilities → Math) + - Operation: **Multiply** + - Connect: Normal Factor (from Map Range) → Value A + - Connect: Occlusion Factor (from Boolean Math or 1.0) → Value B + - Result: **Snow Mask** (0.0 to 1.0, where 1.0 = full snow coverage) + +2. **Math** node (Utilities → Math) + - Operation: **Multiply** + - Connect: Snow Mask → Value A + - Value B: `0.3` (base snow depth in meters - **adjust this to control snow thickness**) + - Result: **Snow Depth** (final depth value in world units, typically 0.0 to 0.3m) + +**Tweaking Snow Coverage:** +- Increase second multiply value (0.3 → 0.5) for deeper snow +- Decrease (0.3 → 0.1) for light dusting +- Adjust Map Range "From Min" to control slope cutoff + +#### 4. Store Snow Depth Attribute + +**Goal:** Save snow depth as a named attribute for baking + +**Add Node:** + +1. **Store Named Attribute** node (Output → Store Named Attribute) + - **Blender 5.0:** This replaced the old "Capture Attribute" workflow + - Name: `snow_depth` (type this exactly - used later for baking) + - Data Type: **Float** + - Domain: **Point** (vertex-based storage) + - Connect: Snow Depth (final value) → Value + - Connect: Geometry (from Group Input) → Geometry + - Connect output Geometry to next node + +**Important:** In Blender 5.0, attributes flow through the Geometry socket, so make sure to chain Store Named Attribute's output Geometry to the next node. + +#### 5. Visual Preview (CRITICAL: Simple Offset Only - No Joining!) + +**Goal:** See snow coverage directly in the viewport while tweaking parameters + +**IMPORTANT - Baking Requirement:** +The visual preview must NOT use Join Geometry or duplicate the mesh. The baking process reads the `snow_depth` attribute from the base terrain vertices. If you join/duplicate geometry, you'll get doubled vertices and the wrong half will have zero values. + +**Correct Setup - Simple Offset (Recommended for Baking):** + +1. **Store Named Attribute** node (Output → Store Named Attribute) + - Connected from previous step (snow depth calculation) + - Name: `snow_depth` + - Data Type: **Float** + - Domain: **Point** + - **This stores the attribute on the original mesh vertices** + +2. **Vector Math** node (Utilities → Vector Math) + - Operation: **Scale** + - Connect: Combine XYZ (0,0,1) → Vector + - Connect: Snow Depth value → Scale + - Result: Offset vector (0, 0, snow_depth) pointing upward + +3. **Set Position** node (Geometry → Set Position) + - Connect: Store Named Attribute (Geometry output) → Geometry + - Connect: Vector Math (Vector output) → Offset + - Position: Leave disconnected (uses default vertex positions) + - **This shifts the SAME vertices upward - no duplication** + +4. **Group Output** node + - Connect: Set Position (Geometry) → Geometry + - This is the final output of your node tree + +**What NOT to Do (Common Mistake):** + +❌ **DON'T do this:** +``` +[Store Named Attribute] + ├─→ [Original Mesh Path] → Join Geometry (socket 0) + └─→ [Set Position] → [Duplicate/Copy] → Join Geometry (socket 1) +``` +This creates 2× vertices and baking will read from the original mesh (zeros). + +✓ **DO this instead:** +``` +[Store Named Attribute] + ↓ +[Set Position] (offset the same vertices) + ↓ +[Group Output] +``` + +**Alternative: No Visual Preview (Simplest for Baking)** + +If you want to keep visualization separate from the baking workflow: + +1. **Remove** Set Position and Vector Math nodes entirely +2. Just connect: Store Named Attribute → Group Output +3. Check `snow_depth` values in **Spreadsheet Editor** instead +4. Add a separate Geometry Nodes modifier for visualization if needed +5. **This is the cleanest approach for reliable baking** + +**Viewport Verification:** +- Open **Spreadsheet Editor** (Blender 5.0) +- Select terrain object +- Look for `snow_depth` column +- Values should range from 0.0 to ~0.3-0.5 +- **This is more reliable than visual preview** + +## Baking Snow Depth to Texture (Blender 5.0) + +### Automated Method (Recommended) + +**Using Python Script:** + +1. Open your terrain `.blend` file in Blender +2. Open **Scripting** workspace +3. **Text → Open** → Navigate to `blender/scripts/generate_snow_depth.py` +4. **(Optional)** If you have multiple Geometry Nodes modifiers (e.g., "Trees" and "Snow"): + - Edit the script's `__main__` section + - Change `modifier_name = None` to `modifier_name = "Snow"` (or your exact modifier name) +5. Click **Run Script** button (▶ icon) + +The script will: +- Automatically find your terrain object (looks for "TerrainPlane" or "terrain") +- Find the Geometry Nodes modifier with `snow_depth` attribute (auto-detect or use specified name) +- Create temporary bake material with Attribute node +- Create bake target image (512×512, 32-bit float) +- Clear existing materials and use bake material only +- Bake `snow_depth` attribute to texture +- Verify bake contains data (not all black) +- Restore original materials +- Export as `textures/snow_depth.exr` (ZIP compressed) +- Clean up temporary resources + +**Expected Output:** +``` +Baking snow depth for: TerrainPlane +Resolution: 512×512 +Found snow modifier: Snow +Ensure object has UV map... +Created bake image: SnowDepth_Bake +Set up bake material with image node: Image Texture +Active node: Image Texture +Baking snow depth attribute to texture... +Bake complete! +Restored original materials +Baked image stats: max=0.3127, avg=0.1234 +✓ Bake contains data (values up to 0.3127m) +Saved to: /path/to/textures/snow_depth.exr +Format: OpenEXR, 32-bit float, ZIP compression +File size: 234.5 KB +Cleaned up temporary resources +``` + +**If You See "WARNING: Baked image appears to be all black":** +1. Check **Spreadsheet Editor** - does `snow_depth` attribute column exist? +2. Verify **Store Named Attribute** node is connected in your Geometry Nodes tree +3. Check modifier is enabled (eye icon in modifier stack) +4. Try specifying the exact modifier name in the script +5. Test with simple values - temporarily set snow depth to constant 1.0 + +**Customization:** +Edit the script's `__main__` section to adjust: +- `resolution=512` → Change to 1024 for higher detail +- Terrain object name matching +- Output path + +### Manual Method (For Understanding) + +If you prefer manual control or need to debug: + +### 1. UV Unwrap Terrain + +**Ensure terrain mesh has proper UVs:** + +1. Select terrain mesh +2. Enter **Edit Mode** (Tab) +3. Select all vertices (A) +4. **UV → Unwrap** or use **Smart UV Project** + - For simple terrain planes: **Unwrap** works well + - For complex terrain: **Smart UV Project** with low Island Margin +5. In **UV Editor**, verify UVs fill the 0-1 space without major distortion +6. Return to **Object Mode** + +**Requirements:** +- UVs should span 0-1 range (check in UV Editor) +- Minimal distortion (for accurate snow placement) +- No overlapping UV islands +- Same UV layout as terrain heightmap (if exported from same mesh) + +### 2. Create Bake Target Image + +**In Shader Editor (with terrain selected):** + +1. Open **Shader Editor** (if not already open) +2. **Add → Texture → Image Texture** node +3. Click **+ New Image** in the node + - Name: `snow_depth` + - Width: `512` (match your terrain heightmap resolution) + - Height: `512` + - Color: **Black** (default) + - **Alpha: Unchecked** (single channel) + - **32-bit Float: CHECKED** ✓ (critical for EXR export) + - **Type:** Blank +4. **Select the Image Texture node** (click it - should have white outline) + - **This selection is CRITICAL** - Blender bakes to the selected Image Texture node + +**Don't connect this node to anything** - it's just a bake target. + +### 3. Set Up Bake Shader (Temporary) + +**Create a simple emission shader to bake the attribute:** + +1. In Shader Editor, **temporarily modify** your terrain material: + - Add **Attribute** node (Input → Attribute) + - Name: `snow_depth` (must match stored attribute name exactly) + - Connect **Color** output → **Emission** shader → **Material Output** + +**Blender 5.0 Note:** You can also use **AOV Output** workflow, but Emission baking is simpler for single-channel data. + +### 4. Bake the Attribute + +1. With terrain mesh selected, open **Render Properties** panel (camera icon) +2. Scroll to **Bake** section +3. **Bake Settings:** + - Bake Type: **Emit** + - Influence → Contributions → **Only Emit** (enabled) + - Margin: **0 px** (no padding needed for terrain UVs) + - Target: **Image Textures** (should be selected by default) + - Clear Image: **Enabled** ✓ (start fresh) +4. Verify Image Texture node is still selected (white outline) +5. Click **Bake** button + +**Baking Progress:** +- Blender will show progress in the status bar +- For 512×512: Should take < 1 second +- For 1024×1024: May take a few seconds + +**Verify Result:** +- Switch Image Texture node to show `snow_depth` image +- You should see white areas where snow accumulates +- Black areas have no snow +- Gray values = partial snow (slopes or partial occlusion) + +### 5. Export as EXR (Blender 5.0) + +**Steps:** + +1. **Switch to UV Editor or Image Editor** (any editor showing the image) +2. Ensure `snow_depth` image is active +3. **Image Menu → Save As** (or Alt+S) +4. **File Browser Settings:** + - Navigate to your project: `meshes/` directory + - Filename: `snow_depth.exr` + - **File Format: OpenEXR** (should auto-select based on .exr extension) + +5. **OpenEXR Settings (Right Panel):** + - **Color Depth: 32-bit Float** (FULL, not HALF) + - **Codec: ZIP** (lossless compression - good balance of size/quality) + - Alternative: **None** (no compression, larger file) + - **Don't use:** DWAA/DWAB (lossy, may lose precision) + - **Color Space: Linear** (not sRGB) + - **Z Buffer: Disabled** (we only need color/depth channel) + +6. Click **Save As Image** button + +**Blender 5.0 Specific Notes:** +- EXR settings may be in a collapsible panel on the right side of the file browser +- If you don't see format options, ensure the filename ends with `.exr` +- Blender 5.0 defaults to 16-bit Half for EXR - **change to 32-bit Float** + +**Verify Export:** + +After saving, re-open the file to verify: +1. **File → External Data → Edit Externally** (opens in system viewer if available) +2. Check file properties: + - Format: OpenEXR + - Bit depth: 32-bit float + - Channels: RGB (or R only - either works, Rust code reads first channel) + - Values: 0.0 to ~0.3-0.5 range (world space meters) + +**Common Issues:** +- **Image looks pure black when saved:** You may need to adjust Image Editor → View → Exposure to see low values +- **File is huge (>10MB for 512×512):** Check codec is set to ZIP, not None +- **Values are clamped 0-1:** Ensure Color Space is Linear, not sRGB + +## Testing the Export (Blender 5.0) + +### In Blender - Visual Verification: + +1. **Geometry Nodes Preview:** + - Ensure Geometry Nodes modifier is enabled (eye icon) + - Switch viewport to **Material Preview** or **Rendered** shading + - Snow should appear on appropriate surfaces (flat, exposed areas) + - Verify snow respects slope limits and overhangs + +2. **Check Attribute Values:** + - Open **Spreadsheet Editor** (new in Blender 5.0 - top menu bar) + - With terrain selected, spreadsheet shows geometry data + - Look for `snow_depth` column in the attribute list + - Values should range from 0.0 (no snow) to ~0.3-0.5 (full snow) + - **Blender 5.0:** Attributes are displayed in a sortable table format + +3. **Verify Coverage:** + - **Flat surfaces:** Should have maximum snow (bright white/high values) + - **Steep slopes (>45°):** Should have minimal/no snow (dark/zero values) + - **Under overhangs:** Should have no snow (zero values) + - **Exposed slopes:** Should have graduated snow based on angle + +4. **Check Baked Texture:** + - In Image Editor, view `snow_depth` image + - **View → Display Settings → Exposure:** Increase if image looks too dark + - White pixels = snow coverage + - Black pixels = no snow + - Gray = partial coverage + +### Quick Test in Blender (Before Game Integration): + +**Method 1: UV Editor Visualization** +1. Split viewport → **UV Editor** +2. Select terrain mesh +3. In UV Editor, select `snow_depth` image from dropdown +4. UVs should align with snow coverage + +**Method 2: Re-import and Verify** +1. Create a test plane +2. Add Image Texture node in Shader Editor +3. Load `meshes/snow_depth.exr` +4. Connect to Emission → Material Output +5. View in Rendered mode - should see snow pattern + +### In Game (After Rust Implementation): + +1. Place `meshes/snow_depth.exr` in project directory +2. Verify file loads without errors: + ```rust + let snow_config = SnowConfig::new( + "meshes/snow_depth.exr", + Vec2::new(1000.0, 1000.0), + (512, 512) + ); + let snow_layer = SnowLayer::load(&mut world, &snow_config)?; + ``` +3. Snow mesh should appear as white layer on terrain +4. Test deformation with player movement +5. Verify trails appear where player walks + +**Troubleshooting:** +- **No snow appears in game:** Check EXR file path and resolution match terrain +- **Snow in wrong location:** Verify terrain UVs match between Blender and game +- **Crashes on load:** Check EXR format is 32-bit float, not 16-bit half + +## Troubleshooting Baking Issues + +### Problem: Baked EXR is Completely Black or Has Wrong Values (SOLVED) + +**Symptoms:** +- Baking script reports data exists (e.g., "max=1.0"), but game loads all zeros +- OR: Baking reports "max=1.0" but Spreadsheet shows "max=5.0" (clamped values) +- File size is suspiciously small (< 100KB for 1000×1000) +- Blender console shows: "No active and selected image texture node found in material X" + +**Root Cause:** +The terrain object has **multiple material slots** (e.g., "heightmap", "terrain", "snow"). The baking operation requires the active image node to be present in **all** material slots, not just the first one. If any slot is missing the setup, the bake fails silently or produces incorrect data. + +**Solution:** +The updated `generate_snow_depth.py` script now: +1. Saves all original materials +2. Temporarily replaces **all material slots** with a single bake material +3. Performs the bake +4. Restores all original materials + +**Verification:** +After running the script, you should see: +``` +Object has 3 material slot(s): ['heightmap', 'terrain', 'snow'] +Temporarily replaced all materials with bake material +Baking with Cycles (EMIT)... +Bake complete! +Baked image stats: max=5.0000, avg=2.5275 +Non-zero pixels: 3469321 (86.7%) +✓ Bake contains data (values up to 5.0000m) +File size: 1863.5 KB +``` + +**If you still get issues:** +- Ensure you're using the latest version of `generate_snow_depth.py` +- Check that the script explicitly mentions "Temporarily replaced all materials" +- Verify file size is > 1MB for 1000×1000 resolution + +### Problem: Baked EXR is Completely Black (All Zero Values) + +**Symptoms:** +- Python script reports: "WARNING: Baked image appears to be all black" +- Spreadsheet Editor shows `snow_depth` attribute with valid values (e.g., max=5.0) +- But exported EXR has all zero values +- Debug output may show: "First 10,404 vertices: all zeros, Second 10,404 vertices: actual values" + +**Root Cause:** +Your Geometry Nodes setup is joining/duplicating geometry, creating 2× vertices. The baking process reads from the base mesh vertices (which have `snow_depth = 0`), not the offset/duplicated vertices (which have the actual values). + +**Diagnosis:** +1. Select terrain object +2. Check vertex count in **Spreadsheet Editor** +3. If vertex count is exactly 2× your base mesh count → you're joining geometry +4. Check your Geometry Nodes for **Join Geometry** or mesh duplication nodes + +**Solution:** + +**Option 1: Remove Visual Offset (Simplest - Recommended)** +1. Open your "Snow Accumulation" Geometry Nodes tree +2. Find and **delete** these nodes: + - Any **Join Geometry** nodes that combine original + offset mesh + - Any mesh duplication/copy nodes + - (Keep Set Position if it's just offsetting, not duplicating) +3. Final node chain should be: + ``` + [Calculate Snow Depth] + ↓ + [Store Named Attribute: "snow_depth"] + ↓ + [Group Output] (no offset, no join) + ``` +4. Re-run the baking script +5. Verify bake in Spreadsheet Editor instead of visual preview + +**Option 2: Fix the Node Structure** +If you have: +``` +[Store Named Attribute] + ├─→ [Original Path] → Join Geometry + └─→ [Set Position Offset] → Join Geometry +``` + +Change to: +``` +[Store Named Attribute] + ↓ +[Set Position] (offset the SAME vertices, don't duplicate) + ↓ +[Group Output] +``` + +**Option 3: Separate Modifiers** +1. Create TWO Geometry Nodes modifiers on terrain: + - Modifier 1: "Snow Data" - Just calculates and stores `snow_depth` attribute + - Modifier 2: "Snow Visualization" - Reads attribute and creates visual offset +2. **Disable Modifier 2** before baking +3. Bake with only Modifier 1 enabled +4. Re-enable Modifier 2 after export + +**Verification After Fix:** +1. Select terrain in Blender +2. Open **Spreadsheet Editor** +3. Check vertex count - should match your base terrain (not doubled) +4. Check `snow_depth` column - should have non-zero values +5. Re-run `generate_snow_depth.py` script +6. Should see: "✓ Bake contains data (values up to X.XXXXm)" + +### Problem: Some Areas Have Snow Values, Others Are Zero + +**Symptoms:** +- Baked EXR has partial data +- Some regions are black (zero) unexpectedly +- Values are present but in wrong locations + +**Possible Causes:** +1. **UV Layout Issue:** + - UVs are overlapping or outside 0-1 range + - Solution: Re-unwrap terrain UVs, ensure no overlap + +2. **Modifier Stack Order:** + - Other modifiers before snow are transforming geometry + - Solution: Move snow modifier to top of stack for testing + +3. **Partial Geometry Selection:** + - Store Named Attribute has a Selection input that's filtering vertices + - Solution: Ensure Selection input is empty (affects all vertices) + +## Troubleshooting Raycast/Occlusion Issues + +### Problem: Raycast Doesn't Detect Any Geometry + +**Diagnosis:** +1. Add **Viewer** node after your Join Geometry +2. Check **Spreadsheet Editor** - does it show geometry? +3. If Spreadsheet is empty → Realize Instances didn't work + +**Solutions:** +- Ensure **Realize Instances** is connected between Collection Info and Join Geometry +- Check Collection Info is set to correct collection +- Verify collection actually has objects in it (check Outliner) +- Try realizing instances twice (sometimes needed for nested instances) + +### Problem: Instanced Trees Don't Block Snow + +**Diagnosis:** +Your tree instances are in a separate geometry nodes modifier. + +**Solution - Modifier Stack Order:** +``` +Terrain Mesh +├─ 1. Tree Scatter (Geometry Nodes) ← MUST BE FIRST +└─ 2. Snow Accumulation (Geometry Nodes) ← SECOND +``` + +**In Snow node tree:** +1. The **Group Input** geometry already includes realized trees from previous modifier +2. **DON'T** realize Group Input again (causes issues) +3. Instead, use **Geometry to Instance** on Group Input, then **Realize Instances** + +**Alternative - Separate Input for Blockers:** + +Add a **Geometry Nodes Modifier Input** for blocker geometry: +1. In modifier properties, add **Input** → **Geometry** +2. Name it: `Blocker_Geometry` +3. Drag your tree-spawning object into this input +4. In node tree, use this input for raycast target + +### Problem: Everything Gets Blocked (No Snow Anywhere) + +**Diagnosis:** +Raycast target includes the terrain itself, or ray direction is wrong. + +**Solutions:** +- Verify Ray Direction is (0, 0, 1) - straight up +- Don't include terrain mesh in blocker geometry +- Check Ray Length isn't too short +- Test with **Boolean Math → NOT** on the Is Hit output + +### Alternative Approach: Vertex Paint Method + +**If raycast continues to fail, use manual vertex painting:** + +1. Skip all raycast nodes +2. Add **Vertex Color** input node (Input → Color Attribute) + - Name: `occlusion_mask` +3. Use as Occlusion Factor (white = exposed, black = blocked) +4. In Edit Mode, use **Vertex Paint** mode to paint occlusion manually +5. More control, but less automatic + +### Alternative Approach: Use Z-Depth + +**For simple overhang detection without raycast:** + +1. **Separate XYZ** node + - Connect: Position → Vector + - Use Z output +2. **Compare** node + - A: Z position + - B: Threshold value (height above which = no overhang) + - Greater Than +3. Use as simple occlusion approximation +4. Works for simple overhangs, not complex geometry + +## Iteration Workflow + +**To adjust snow coverage:** +1. Modify normal threshold (Map Range "From Min" value - lower = more snow on slopes) +2. Adjust base snow depth multiplier (final Math → Multiply value) +3. Re-bake attribute to texture (Render → Bake) +4. Re-export EXR (Image → Save As) +5. Restart game to reload (hot-reload not implemented yet) + +**For occluding objects:** +- Add/remove objects from `Snow Blockers` collection +- Move tree modifier above snow modifier if needed +- Re-bake to update occlusion +- Don't forget **Realize Instances** step! + +**Performance Optimization:** +- Lower bake resolution for faster iteration (256×256 for testing) +- Disable raycast (use Value = 1.0) while tweaking slopes +- Only enable occlusion for final bake +- Use simpler blocker geometry during authoring + +## Advanced: Working with Instanced Trees + +### Complete Example: Trees from Scatter Modifier + +**Scenario:** You have a "Tree Scatter" geometry nodes modifier that instances trees, and you want those trees to block snow. + +**Step 1: Modifier Stack Setup** +``` +Terrain Object +├─ Geometry Nodes: "Tree Scatter" ← Position 1 (FIRST) +└─ Geometry Nodes: "Snow Accumulation" ← Position 2 (SECOND) +``` + +**Step 2: In Tree Scatter Node Tree** +- Outputs instanced trees on the terrain +- No special setup needed +- Just ensure instances are actually being created + +**Step 3: In Snow Accumulation Node Tree** + +**Method A: Use Incoming Geometry (Simpler)** +``` +[Group Input] (contains terrain + tree instances from previous modifier) + ├─→ [Mesh to Points] → [Points to Extract Trees] + │ + ├─→ [Delete Geometry] → [Keep Only Terrain for Snow] + │ ↑ + │ └─ Selection: Invert (keeps terrain, removes instances) + │ + └─→ [Realize Instances] → [Tree Geometry for Raycast] + └─→ [Join Geometry] ← (blocker geometry) +``` + +**Method B: Reference Tree Object Directly** +1. Put actual tree mesh in `Snow Blockers` collection +2. Use Collection Info → Realize Instances → Join Geometry +3. Simpler but doesn't respect scattering variations + +**Step 4: Connect to Raycast** +``` +[Join Geometry] (all blockers) + ├─→ [Raycast to Geometry] (Target Geometry) + +[Position] ───→ [Raycast to Geometry] (Source Position) +[Combine XYZ(0,0,1)] ───→ [Raycast to Geometry] (Ray Direction) + +[Raycast to Geometry] (Is Hit) ───→ [Boolean Math: NOT] ───→ Occlusion Factor +``` + +### Node Tree Layout Example + +**For terrain with separate tree instances:** + +``` +┌─────────────────────────────────────────────────────┐ +│ BLOCKER PREPARATION (Top of tree) │ +├─────────────────────────────────────────────────────┤ +│ [Collection Info: Snow Blockers] │ +│ ↓ │ +│ [Realize Instances] │ +│ ↓ │ +│ [Join Geometry] ← (socket 0) │ +│ ↓ │ +│ (blocker_geo) ──────────────┐ │ +└────────────────────────────────┼────────────────────┘ + │ +┌────────────────────────────────┼────────────────────┐ +│ TERRAIN PROCESSING ↓ │ +├─────────────────────────────────────────────────────┤ +│ [Group Input: Geometry] │ +│ ├─→ [Position] ─────────┐ │ +│ │ ↓ │ +│ └─→ [Normal] ──→ [Dot Product] → ... │ +│ ↓ │ +│ [Raycast to Geometry] │ +│ ↑ │ +│ (blocker_geo) │ +│ ↓ │ +│ [Boolean Math: NOT] │ +│ ↓ │ +│ (occlusion_factor) │ +└─────────────────────────────────────────────────────┘ +``` + +## Advanced: Directional Snow Accumulation + +**Optional enhancement for wind-driven snow:** + +1. Replace raycast up direction (0,0,1) with angled vector + - Example: `(0.3, 0, 1)` for wind from +X direction + - Normalize this vector for consistent results +2. **Vector Math** node: Normalize + - Input: Combine XYZ (0.3, 0, 1) + - Output → Ray Direction +3. Creates asymmetric accumulation (more snow on lee side) +4. Combine with normal factor to prevent snow on windward slopes + +**Advanced Normal Calculation:** +``` +Wind Direction: (-0.3, 0, -1) normalized +↓ +[Vector Math: Dot Product] +├─ Normal (surface normal) +└─ Wind Direction +↓ +[Map Range] +├─ From Min: -0.5 (windward side) +├─ From Max: 0.5 (lee side) +└─ Result: More snow on protected slopes +``` + +## Quick Start: Minimal Working Setup (No Occlusion) + +**If you're having trouble with raycast, start with this simple version first:** + +### Phase 1: Normal-Based Snow Only + +1. **Create Geometry Nodes modifier** on terrain +2. **Add these nodes only:** + ``` + [Group Input] + ↓ + [Normal] → [Vector Math: Dot] ← [Combine XYZ: 0,0,1] + ↓ + [Map Range: 0.7→1.0 to 0→1] + ↓ + [Math: Multiply by 0.3] → (snow_depth value) + ↓ + [Store Named Attribute: "snow_depth"] + ↓ + [Group Output] + ``` + +3. **Test:** Switch to Spreadsheet Editor, verify `snow_depth` column exists +4. **Bake and export** following steps above +5. **Verify in game** - snow should appear on flat surfaces + +### Phase 2: Add Occlusion Later + +Once Phase 1 works: + +1. **Add Collection Info → Realize Instances → Join Geometry** +2. **Insert Raycast** between Map Range and final Multiply +3. **Multiply:** normal_factor × occlusion_factor × 0.3 +4. **Debug:** Use Viewer node to verify blocker geometry exists +5. **Re-bake and test** + +## Debugging Raycast Step-by-Step + +**Test 1: Verify Blocker Geometry Exists** +1. Add **Viewer** node after **Realize Instances** +2. Select terrain object +3. Open **Spreadsheet Editor** +4. Check "Viewer" data source +5. Should show vertices/faces from blocker objects + - **If empty:** Realize Instances isn't working + - **If has data:** Geometry exists, raycast should work + +**Test 2: Visualize Raycast Results** +1. Add **Store Named Attribute** node + - Name: `is_occluded` + - Value: Connect "Is Hit" output from Raycast + - Data Type: Boolean → Float (convert with Math node if needed) +2. Bake this attribute to see occlusion map +3. White = blocked, Black = exposed +4. If all black/white uniformly → raycast isn't working + +**Test 3: Simple Cube Test** +1. Add a cube above your terrain +2. Add cube to `Snow Blockers` collection +3. Position cube to cast obvious shadow +4. Re-calculate geometry nodes +5. Should see circular area of no snow under cube + - **If no effect:** Raycast isn't seeing the cube + - **If works:** Your collection setup is correct + +**Test 4: Check Ray Parameters** +``` +Position: Should be terrain vertex positions (auto from Position node) +Direction: Must be (0, 0, 1) exactly +Length: Try 1000.0 (very large) to rule out length issues +Target Geo: Must have realized geometry (not instances) +``` + +## Export Checklist + +### Blender Setup: +- [ ] Terrain has proper UVs (checked in UV Editor) +- [ ] Geometry Nodes modifier applied and enabled (eye icon) +- [ ] `snow_depth` attribute visible in Spreadsheet Editor +- [ ] Values in reasonable range (0.0 to ~0.3-0.5) +- [ ] Tested in Blender viewport (visual preview with Set Position offset) + +### Baking (Choose One Method): + +**Automated (Recommended):** +- [ ] Run `blender/scripts/generate_snow_depth.py` in Blender +- [ ] Verify console output shows successful bake +- [ ] Check `textures/snow_depth.exr` exists + +**Manual:** +- [ ] Baked to image texture (512×512 or 1024×1024, 32-bit float) +- [ ] Image shows expected snow pattern (white on flat areas) +- [ ] Saved as `textures/snow_depth.exr` (OpenEXR format, ZIP codec) +- [ ] Re-opened file to verify export settings (32-bit float, not 16-bit half) + +### Verification: +- [ ] File size reasonable (512×512 should be 200-500KB with ZIP) +- [ ] File path: `textures/snow_depth.exr` relative to project root +- [ ] Ready for game integration + +### Game Integration: +- [ ] Update `SnowConfig` path if different from default +- [ ] Load snow layer: `SnowLayer::load(&mut world, &config)?` +- [ ] Hook up deformation to player movement system +- [ ] Test snow rendering and trail deformation + +## Common Blender 5.0 Gotchas + +1. **Realize Instances is REQUIRED** - Collection Info outputs instances by default +2. **Modifier stack order matters** - Tree scatter must be BEFORE snow +3. **32-bit Float must be set manually** - Blender defaults to 16-bit Half for EXR +4. **Store Named Attribute replaces Capture Attribute** - Old tutorials won't work +5. **Object Info doesn't take collections** - Use Collection Info instead +6. **Instances are invisible to raycast** - Must realize first +7. **Group Input includes previous modifiers** - Can be used for tree instances diff --git a/CLAUDE.md b/CLAUDE.md index 2ed7676..e293e66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,14 +81,13 @@ As of December 2025, SDL3 Rust bindings are usable but still maturing: **Using wgpu instead of OpenGL:** - Modern GPU API abstraction (Vulkan/Metal/DX12/OpenGL backends) - Better cross-platform support -- WGSL shader language (WebGPU Shading Language) +- WESL shader language (WebGPU Shading Language) - Type-safe API with explicit resource management - Low-res framebuffer rendering with 3-bit RGB dithering (retro aesthetic) **Rendering Architecture:** - wgpu for 3D mesh rendering with custom shaders - Low-resolution framebuffer (160×120) upscaled to window size -- Bayer 8×8 dithering for 3-bit RGB color (8 colors total) - Multiple rendering pipelines: standard meshes and terrain - Separate bind groups for different material types @@ -141,7 +140,6 @@ SDL Events → InputState → player_input_system() → InputComponent → movem **wgpu Rendering System:** - Low-res framebuffer (160×120) renders to texture -- Bayer 8×8 dithering reduces colors to 3-bit RGB (8 colors) - Final blit pass upscales framebuffer to window using nearest-neighbor sampling - Depth buffer for 3D rendering with proper occlusion @@ -199,10 +197,10 @@ cargo fmt ## Shader Files -WGSL shaders are stored in the `shaders/` directory: -- `shaders/standard.wgsl` - Standard mesh rendering with directional lighting -- `shaders/terrain.wgsl` - Terrain rendering with shadow mapping (no displacement) -- `shaders/blit.wgsl` - Fullscreen blit for upscaling low-res framebuffer +WESL shaders are stored in the `src/shaders/` directory: +- `src/shaders/standard.wesl` - Standard mesh rendering with directional lighting +- `src/shaders/terrain.wesl` - Terrain rendering with shadow mapping (no displacement) +- `src/shaders/blit.wgsl` - Fullscreen blit for upscaling low-res framebuffer Shaders are loaded at runtime via `std::fs::read_to_string()`, allowing hot-reloading by restarting the application. @@ -230,7 +228,7 @@ Shaders are loaded at runtime via `std::fs::read_to_string()`, allowing hot-relo **Rendering:** - `render.rs` - wgpu renderer, pipelines, bind groups, DrawCall execution -- `shader.rs` - Standard mesh shader (WGSL) with diffuse+ambient lighting +- `shader.rs` - Standard mesh shader (WESL) with diffuse+ambient lighting - `terrain.rs` - Terrain entity spawning, glTF loading, EXR heightmap → physics collider - `postprocess.rs` - Low-res framebuffer and blit shader for upscaling - `mesh.rs` - Vertex/Mesh structs, plane/cube mesh generation, glTF loading @@ -578,86 +576,3 @@ Time::init(); // In main() before game loop let time = Time::get_time_elapsed(); // Anywhere in code ``` -## Current Implementation Status - -### Implemented Features - -**ECS Architecture:** -- ✅ Full ECS conversion completed -- ✅ Entity system with EntityManager (spawn/despawn/query) -- ✅ Component storages (Transform, Mesh, Physics, Movement, Input, PlayerTag, StateMachine) -- ✅ Systems pipeline (input → state machine → physics → physics sync → render) -- ✅ No `Rc>` - clean component ownership -- ✅ Event bus integrated as complementary to systems - -**Core Rendering:** -- ✅ wgpu renderer with Vulkan backend -- ✅ Low-res framebuffer (160×120) with Bayer dithering -- ✅ Multiple render pipelines (standard mesh + terrain) -- ✅ Directional lighting with diffuse + ambient -- ✅ Terrain rendering (glTF with baked heights, no shader displacement) -- ✅ EXR heightmap loading for physics colliders -- ✅ glTF mesh loading -- ✅ render_system (ECS-based DrawCall generation) - -**Input System:** -- ✅ Two-layer input pipeline (InputState → InputComponent) -- ✅ player_input_system converts raw input to gameplay commands -- ✅ SDL event handling in InputState -- ✅ Per-entity InputComponent for controllable entities - -**Camera & Debug:** -- ✅ 3D camera with rotation (yaw/pitch) -- ✅ Noclip mode for development (in debug/noclip.rs) -- ✅ Mouse look with relative mouse mode -- ✅ Toggle with 'I' key, 'N' for noclip mode - -**Physics:** -- ✅ rapier3d integration with PhysicsManager singleton -- ✅ PhysicsComponent storage (rigidbody/collider handles) -- ✅ physics_sync_system (syncs physics → transforms) -- ✅ Physics step integrated into game loop -- ⚠️ Ground detection not yet implemented -- ⚠️ Movement physics not yet connected - -**State Machines:** -- ✅ Generic StateMachine implementation -- ✅ StateMachineStorage (ECS component) -- ✅ state_machine_system updates all state machines -- ✅ Transitions can query ECS components -- ⚠️ Player state transitions not yet configured - -**Player:** -- ✅ Player entity spawning function -- ✅ Components: Transform, Mesh, Physics, Movement, Input, PlayerTag -- ⚠️ Movement system not yet implemented -- ⚠️ State machine not yet attached to player -- ⚠️ Currently inactive (noclip camera used instead) - -**Movement Configuration:** -- ✅ Horizontal movement config (Bezier acceleration curves) -- ✅ Vertical movement config (jump mechanics) -- ✅ MovementComponent storage -- ⚠️ Movement system not yet implemented -- ⚠️ Not yet integrated with physics - -### Not Yet Implemented - -- ❌ Movement system (apply InputComponent → physics velocities) -- ❌ Ground detection and collision response -- ❌ Player state machine configuration -- ❌ Camera follow behavior (tracks player entity) -- ❌ Snow deformation compute shaders -- ❌ Debug UI system - -### Current Focus - -**ECS migration is complete!** The architecture is now fully entity-component-system based with clean separation of data and logic. The next steps are: - -1. Implement movement_system to apply InputComponent to physics -2. Configure player state machine transitions -3. Implement ground detection -4. Add camera follow system -5. Integrate snow deformation - -The noclip camera mode serves as the primary navigation method for testing. Press 'N' to toggle noclip mode. diff --git a/Cargo.lock b/Cargo.lock index e07770e..647ede5 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,21 +9,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "aligned" -version = "0.4.3" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", + "memchr", ] [[package]] @@ -41,6 +32,22 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + [[package]] name = "anyhow" version = "1.0.100" @@ -56,23 +63,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -80,12 +70,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "as-slice" -version = "0.2.1" +name = "ascii-canvas" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" dependencies = [ - "stable_deref_trait", + "term", ] [[package]] @@ -103,55 +93,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.17", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" -dependencies = [ - "arrayvec", -] - [[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bit-set" version = "0.8.0" @@ -179,15 +132,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - [[package]] name = "block" version = "0.1.6" @@ -195,10 +139,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" [[package]] -name = "built" -version = "0.8.0" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] [[package]] name = "bumpalo" @@ -238,18 +185,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" -[[package]] -name = "cc" -version = "1.2.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -274,10 +209,13 @@ dependencies = [ ] [[package]] -name = "color_quant" -version = "1.1.0" +name = "convert_case" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "core-foundation" @@ -307,12 +245,12 @@ dependencies = [ ] [[package]] -name = "core2" -version = "0.4.0" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "memchr", + "libc", ] [[package]] @@ -355,6 +293,49 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "document-features" version = "0.2.12" @@ -385,26 +366,6 @@ dependencies = [ "log", ] -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -435,26 +396,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "fdeflate" version = "0.3.7" @@ -465,10 +406,10 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.5" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" @@ -480,6 +421,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -520,25 +467,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" [[package]] -name = "getrandom" -version = "0.3.4" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", + "typenum", + "version_check", ] [[package]] @@ -824,38 +759,13 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", - "exr", - "gif", - "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core 0.5.0", - "zune-jpeg 0.5.7", + "zune-core", + "zune-jpeg", ] -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - [[package]] name = "indexmap" version = "2.12.1" @@ -872,17 +782,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "itertools" version = "0.14.0" @@ -904,16 +803,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] - [[package]] name = "js-sys" version = "0.3.83" @@ -924,6 +813,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -952,6 +850,37 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lalrpop" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -964,22 +893,78 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "lexical" +version = "7.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc8a009b2ff1f419ccc62706f04fe0ca6e67b37460513964a3dfdb919bb37d6" +dependencies = [ + "lexical-core", +] + +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libc" version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - [[package]] name = "libloading" version = "0.8.9" @@ -1018,12 +1003,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "loop9" -version = "0.1.5" +name = "logos" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +checksum = "ff472f899b4ec2d99161c51f60ff7075eeb3097069a36050d8037a6325eb8154" dependencies = [ - "imgref", + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "192a3a2b90b0c05b27a0b2c43eecdb7c415e29243acc3f89cc8247a5b693045c" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "rustc_version", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "605d9697bcd5ef3a42d38efc51541aa3d6a4a25f7ab6d1ed0da5ac632a26b470" +dependencies = [ + "logos-codegen", ] [[package]] @@ -1045,16 +1055,6 @@ dependencies = [ "rawpointer", ] -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - [[package]] name = "memchr" version = "2.7.6" @@ -1180,21 +1180,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - [[package]] name = "num-bigint" version = "0.4.6" @@ -1352,10 +1337,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] -name = "pastey" -version = "0.1.1" +name = "petgraph" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] [[package]] name = "pkg-config" @@ -1398,13 +1396,10 @@ dependencies = [ ] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "presser" @@ -1449,21 +1444,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - [[package]] name = "quote" version = "1.0.42" @@ -1473,41 +1453,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom", -] - [[package]] name = "range-alloc" version = "0.1.4" @@ -1539,56 +1484,6 @@ dependencies = [ "wide", ] -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand", - "rand_chacha", - "simd_helpers", - "thiserror 2.0.17", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -1601,16 +1496,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - [[package]] name = "rayon-core" version = "1.13.0" @@ -1630,18 +1515,41 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "renderdoc-sys" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - [[package]] name = "robust" version = "1.2.0" @@ -1671,6 +1579,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1692,6 +1609,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1718,6 +1644,12 @@ version = "0.5.11+SDL3-3.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73979b5f78819ede7fb6b7534161fe70f3d7a56cc09e7e29c7b58c2b525abfa6" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1762,10 +1694,14 @@ dependencies = [ ] [[package]] -name = "shlex" -version = "1.3.0" +name = "sha3" +version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] [[package]] name = "simba" @@ -1787,13 +1723,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] -name = "simd_helpers" -version = "0.1.0" +name = "siphasher" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" @@ -1825,7 +1758,6 @@ dependencies = [ "exr", "glam 0.30.9", "gltf", - "half", "image", "kurbo", "nalgebra", @@ -1833,6 +1765,7 @@ dependencies = [ "rapier3d", "sdl3", "serde_json", + "wesl", "wgpu", ] @@ -1869,6 +1802,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "syn" version = "2.0.111" @@ -1880,6 +1825,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "term" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" +dependencies = [ + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -1930,17 +1884,26 @@ dependencies = [ ] [[package]] -name = "tiff" -version = "0.10.3" +name = "tokrepr" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "e45a78afc8866f3b814539399590346083be88990c642bf9d95aa9ee0d93627e" dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg 0.4.21", + "proc-macro2", + "quote", + "tokrepr-derive", +] + +[[package]] +name = "tokrepr-derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc2d9fffd3c8ace307ee6735c35ee6fe87d8d4ef64b6a23dc54a15e076692f1b" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1955,29 +1918,30 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "urlencoding" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - [[package]] name = "version_check" version = "0.9.5" @@ -1985,12 +1949,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "wit-bindgen", + "same-file", + "winapi-util", ] [[package]] @@ -2062,10 +2027,33 @@ dependencies = [ ] [[package]] -name = "weezl" -version = "0.1.12" +name = "wesl" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +checksum = "f90d9ca2b0c4b40b0d29aaec92c2212803b8056c71d547c47c12506f9fe7e5de" +dependencies = [ + "annotate-snippets", + "derive_more", + "half", + "itertools", + "num-traits", + "thiserror 2.0.17", + "wesl-macros", + "wgsl-parse", +] + +[[package]] +name = "wesl-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c295ac6a3af945b1c1860dd55aca89691bf7c2ea35b596ce74dba989d4711" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn", + "wgsl-parse", +] [[package]] name = "wgpu" @@ -2218,6 +2206,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wgsl-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cd898e1824e6ad70ee89dea1c3cc0905ad10940004341c5b0b35a0c427a03d" +dependencies = [ + "annotate-snippets", + "derive_more", + "itertools", + "lalrpop", + "lalrpop-util", + "lexical", + "logos", + "thiserror 2.0.17", + "tokrepr", +] + [[package]] name = "wide" version = "0.7.33" @@ -2380,24 +2385,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - [[package]] name = "zerocopy" version = "0.8.31" @@ -2418,12 +2411,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.0" @@ -2439,20 +2426,11 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - [[package]] name = "zune-jpeg" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d915729b0e7d5fe35c2f294c5dc10b30207cc637920e5b59077bfa3da63f28" dependencies = [ - "zune-core 0.5.0", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index ed9d350..299a907 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,13 @@ glam = "0.30" anyhow = "1.0" rapier3d = "0.31" bytemuck = { version = "1.14", features = ["derive"] } -gltf = "1.4" -image = { version = "0.25", features = ["exr"] } +gltf = { version = "1.4", features = ["KHR_lights_punctual", "extras"] } +image = { version = "0.25", default-features = false, features = ["png"] } exr = "1.72" -half = "2.4" kurbo = "0.11" nalgebra = { version = "0.34.1", features = ["convert-glam030"] } serde_json = "1.0" +wesl = "0.2" + +[build-dependencies] +wesl = "0.2" diff --git a/QWEN.md b/QWEN.md new file mode 100644 index 0000000..d61135e --- /dev/null +++ b/QWEN.md @@ -0,0 +1,351 @@ +# Snow Trail SDL Project + +## Model Usage Guide + +**When interacting with this codebase, I follow a step-by-step, concise approach:** + +1. **Start with exploration**: Read files to understand context before making changes +2. **Build incrementally**: Make small, targeted changes and verify them +3. **Test after changes**: Run `cargo check` and relevant tests +4. **Keep explanations brief**: Code should speak for itself; comments only for complex logic +5. **Follow existing patterns**: Mimic the style, structure, and conventions in the codebase + +**For task management**, I use the todo list to track multi-step work: +- Mark tasks as `in_progress` when starting +- Mark tasks as `completed` immediately after finishing +- Add new tasks when scope expands +- Never batch multiple completions + +## Project Overview + +This is a pure Rust game engine implementation (not a game yet) that serves as a migration from a Godot-based project. It's a 3D game using SDL3 for windowing/input, wgpu for rendering, rapier3d for physics, and features a low-res retro aesthetic with dithering. + +The project implements an ECS (Entity Component System) architecture without engine dependencies, providing core systems for rendering, physics, input handling, and entity management. + +### Key Technologies +- **SDL3**: Windowing and input handling (latest stable bindings) +- **wgpu**: Modern GPU API abstraction for rendering (Vulkan/Metal/DX12/OpenGL backends) +- **rapier3d**: Fast 3D physics engine +- **glam**: Fast vector/matrix math library +- **gltf**: Loading 3D models in glTF format +- **exr**: Loading EXR heightmap files for physics colliders +- **kurbo**: Bezier curve evaluation for movement acceleration curves + +### Architecture: Pure ECS +- **Entities**: Just IDs (`EntityHandle = u64`), managed by `EntityManager` +- **Components**: Pure data structures stored in component storages (HashMap) +- **Systems**: Functions that query entities with specific component combinations +- **No Rc>** - Clean ownership model with components as data in hashmaps +- **Component Storages** owned by single `World` struct + +## Building and Running + +### Build Commands +```bash +cargo build +cargo build --release +cargo check +cargo test +cargo run +cargo fmt +``` + +### Shader Compilation +The project uses a custom shader compilation system via the `wesl` crate: +- WGSL/WESL shaders are compiled at build time via `build.rs` +- Shaders are loaded at runtime via `std::fs::read_to_string()`, allowing hot-reloading by restarting the application +- Build artifact: `standard` shader package + +### Runtime Behavior +- Window resolution: 800×600 (resizable) +- Rendering resolution: Low-res framebuffer (160×120) upscaled to window +- Target FPS: 60 FPS with fixed timestep physics (1/60s) +- Default mode: Noclip camera active +- Toggle modes: Press 'N' to toggle noclip/follow modes + +## Development Conventions + +### Code Style (from CLAUDE.md) +- **NO inline comments unless ABSOLUTELY necessary** - Code must be self-documenting +- **Doc comments (`///`)** only for public APIs and complex algorithms +- **All `use` statements must be at the file level** (module top), not inside function bodies +- **NO inline paths** - always add `use` statements at the top of files +- **Formatting**: `brace_style = "AlwaysNextLine"`, `control_brace_style = "AlwaysNextLine"` + +### File Structure +``` +src/ +├── main.rs - SDL3 event loop, game loop orchestration, system execution order +├── entity.rs - EntityManager for entity lifecycle (spawn/despawn/query) +├── world.rs - World struct owning all component storages +├── camera.rs - 3D camera with rotation and follow behavior +├── physics.rs - PhysicsManager singleton (rapier3d world) +├── player.rs - Player entity spawning function +├── terrain.rs - Terrain entity spawning, glTF loading, EXR heightmap loading +├── render.rs - wgpu renderer, pipelines, bind groups, DrawCall execution +├── postprocess.rs - Low-res framebuffer and blit shader for upscaling +├── mesh.rs - Vertex/Mesh structs, plane/cube mesh generation, glTF loading +├── shader.rs - Standard mesh shader (WGSL) with diffuse+ambient lighting +├── state.rs - Generic StateMachine implementation +├── event.rs - Type-safe event bus (complementary to ECS) +├── picking.rs - Ray casting for mouse picking (unused currently) +├── heightmap.rs - EXR heightmap loading utilities +├── draw.rs - DrawManager (legacy, kept for compatibility) +├── texture_loader.rs - Texture loading utilities +└── systems/ - ECS systems (input, state_machine, physics_sync, render, camera) + ├── input.rs + ├── state_machine.rs + ├── physics_sync.rs + ├── render.rs + ├── camera_follow.rs + ├── camera_input.rs + └── camera_noclip.rs +├── components/ - ECS component definitions + ├── input.rs + ├── mesh.rs + ├── movement.rs + ├── physics.rs + ├── player_tag.rs + ├── jump.rs + ├── camera.rs + └── camera_follow.rs +├── utility/ - Utility modules + ├── input.rs - InputState (raw SDL input handling) + ├── time.rs - Time singleton (game time tracking) + └── transform.rs - Transform struct (position/rotation/scale data type) +├── debug/ - Debug utilities + ├── noclip.rs - Noclip camera controller + └── render_collider_debug.rs +└── shaders/ - WGSL/WESL shader files + ├── shared.wesl - Shared shader utilities + ├── standard.wesl - Standard mesh rendering with directional lighting + ├── terrain.wesl - Terrain rendering with shadow mapping + └── blit.wgsl - Fullscreen blit for upscaling low-res framebuffer +``` + +### System Execution Order (main.rs game loop) +1. **SDL Events → InputState**: Poll events, handle raw input +2. **InputState → InputComponent**: `player_input_system()` converts raw input to gameplay commands +3. **State Machine Update**: `state_machine_physics_system()` and `state_machine_system()` +4. **Physics Simulation**: Fixed timestep physics step +5. **Physics → Transforms**: `physics_sync_system()` syncs physics bodies to transforms +6. **Rendering**: `render_system()` generates DrawCalls, renderer executes pipeline +7. **Cleanup**: Clear just-pressed states + +### ECS Component Storages +All storages are owned by the `World` struct: +- `TransformStorage` - Position, rotation, scale +- `MeshStorage` - Mesh data + render pipeline +- `PhysicsStorage` - Rapier3d rigidbody/collider handles +- `MovementStorage` - Movement config + state +- `JumpStorage` - Jump mechanics state +- `InputStorage` - Gameplay input commands +- `PlayerTagStorage` - Marker for player entities +- `StateMachineStorage` - Behavior state machines +- `CameraStorage` - Camera components +- `CameraFollowStorage` - Camera follow behavior + +### Input Handling (Two-Layer Pipeline) +**Layer 1: Raw Input** (`utility/input.rs` - `InputState`): +- Global singleton for SDL event handling +- Tracks raw hardware state (W/A/S/D pressed, mouse delta, etc.) +- Handles SDL events via `handle_event()` method + +**Layer 2: Gameplay Commands** (`components/input.rs` - `InputComponent`): +- Per-entity ECS component +- Stores processed gameplay commands (move_direction, jump_pressed) +- Filled by `player_input_system()` which reads `InputState` + +**Input Flow**: SDL Events → InputState → InputComponent → Movement Systems + +**Current Controls**: +- `W/A/S/D`: Movement +- `Space`: Jump +- `Shift`: Speed boost (noclip mode) +- `I`: Toggle mouse capture +- `Escape`: Quit game +- `N`: Toggle noclip/follow mode +- Mouse motion: Camera look (yaw/pitch) + +### Rendering Pipeline +- **Low-res framebuffer** (160×120) renders to texture +- **Bayer 8×8 dithering** reduces colors to 3-bit RGB (8 colors) +- **Final blit pass** upscales framebuffer to window using nearest-neighbor sampling +- **Depth buffer** for 3D rendering with proper occlusion +- **Multiple render pipelines**: Standard mesh and terrain pipelines +- **Directional lighting**: Diffuse + ambient (basic Phong model) + +### Terrain System +- **glTF mesh** exported from Blender 5.0 with baked height values in vertices +- **EXR heightmap** loaded for physics colliders (single-channel R32Float format) +- **Heightfield collider** created directly from EXR data +- **No runtime displacement** - vertices rendered directly +- **Separate terrain pipeline** for terrain-specific rendering + +### Build System +- **`build.rs`**: Custom build script using `wesl` crate +- **Shader compilation**: `package::standard` → `standard` artifact +- **No external dependencies** needed for shader compilation at build time + +### Dependencies Rationale +- **sdl3**: Modern SDL3 bindings for future-proofing +- **wgpu**: Modern GPU API with cross-platform support +- **rapier3d**: Fast physics engine with good Rust integration +- **gltf**: Standard 3D model format for asset pipeline +- **exr**: High-dynamic-range heightmap loading for physics +- **kurbo**: Bezier curves for smooth movement acceleration +- **bytemuck**: Safe byte casting for GPU buffer uploads + +## Current Implementation Status + +### Implemented Features +✅ Full ECS architecture (entities, components, systems) +✅ SDL3 windowing and input handling +✅ wgpu rendering with low-res framebuffer +✅ Multiple render pipelines (standard mesh + terrain) +✅ Bayer dithering for retro aesthetic +✅ glTF mesh loading +✅ EXR heightmap loading for physics +✅ rapier3d physics integration +✅ State machine system (generic implementation) +✅ Event bus (complementary to ECS) +✅ Camera system (free look + follow modes) +✅ Noclip mode for development +✅ Two-layer input pipeline + +### In Progress +⚠️ Movement system (apply InputComponent → physics velocities) +⚠️ Ground detection and collision response +⚠️ Player state machine configuration (transitions not yet set up) +⚠️ Camera follow behavior (partial implementation) +⚠️ Snow deformation compute shaders +⚠️ Debug UI system + +### Known Limitations +- Player entity spawns but is inactive (noclip camera used for testing) +- Movement physics not yet connected to input +- Ground detection not implemented +- State machine transitions not configured +- Camera follow needs refinement + +## Content Creation Workflow + +### Blender 5.0 (blender/) +- **Terrain modeling**: `terrain.blend` +- **Player character**: `player_mesh.blend` +- **Export formats**: + - glTF: `meshes/` for rendering (baked heights in vertices) + - EXR: `textures/` single-channel float heightmap for physics + +### GIMP (gimp/) +- **Dither patterns**: `dither_patterns.xcf` (Bayer matrix patterns) + +### Export Process +1. Model terrain in Blender 5.0 +2. Export as glTF with baked height values +3. Export same terrain as EXR heightmap +4. Both files represent same data (visual/physics sync guaranteed) + +## Future Development + +### Next Steps (from CLAUDE.md) +1. Implement `movement_system` to apply `InputComponent` to physics velocities +2. Configure player state machine transitions (idle → walking → jumping → falling) +3. Implement ground detection (raycasting with QueryPipeline) +4. Add camera follow system (tracks player entity) +5. Integrate snow deformation compute shaders +6. Implement debug UI system for parameter tweaking + +### Testing Strategy +- Systems are pure functions (easy to test) +- Create multiple `World` instances for isolation +- Query patterns are predictable +- State machine transitions are testable + +## Technical Notes + +### EXR Heightmap Loading (Physics Only) +```rust +use exr::prelude::{ReadChannels, ReadLayers}; + +let builder = exr::Image::new("heightmap.exr") + .no_deep_data() + .largest_resolution_level() + .all_channels() + .all_layers() + .all_attributes(); +``` + +### Component Storage Pattern +```rust +pub struct TransformStorage { + pub components: HashMap, +} + +impl TransformStorage { + pub fn with_mut(&mut self, entity: EntityHandle, f: F) -> Option + where + F: FnOnce(&mut Transform) -> R, + { + self.components.get_mut(&entity).map(f) + } +} +``` + +### State Machine Integration +- TypeId-based state identification +- Transitions as closures (can capture entity ID) +- State callbacks receive `&mut World` for component access +- Safe pattern: Remove → Update → Insert to avoid borrow conflicts + +### Event System (Complementary to ECS) +- Handles irregular, one-time occurrences +- Cross-system messaging without tight coupling +- Global `add_listener()` and `emit()` functions +- Example: FootstepEvent for snow deformation + +### Shader Hot-Reloading +- Shaders loaded at runtime via `std::fs::read_to_string()` +- Restart application to reload shaders +- No recompilation needed + +## Quick Reference + +### Running the Project +```bash +cd /home/jonas/projects/snow_trail_sdl +cargo run +``` + +### Building for Release +```bash +cargo build --release +``` + +### Formatting Code +```bash +cargo fmt +``` + +### Toggling Modes at Runtime +- Press **N** to toggle between noclip and follow modes +- Press **I** to toggle mouse capture +- Press **Escape** to quit + +### Working with Shaders +- Edit `.wesl` files in `src/shaders/` +- Changes take effect on application restart +- Build script compiles `package::standard` → `standard` artifact + +### Adding New Components +1. Define component struct (pure data, no Rc) +2. Add storage to `world.rs` (HashMap) +3. Add storage to `World` struct +4. Update `World::despawn()` to clean up component +5. Create systems that query and modify the component + +### Adding New Systems +1. Add function in `systems/` directory +2. Import at top of `main.rs` +3. Add to system execution order in game loop +4. Systems receive `&mut World` (or `&World` for read-only) diff --git a/blender/scripts/README.md b/blender/scripts/README.md new file mode 100644 index 0000000..830f2d7 --- /dev/null +++ b/blender/scripts/README.md @@ -0,0 +1,139 @@ +# Blender Export Scripts + +Python scripts for generating textures and heightmaps from Blender terrain meshes. + +## Prerequisites + +- Blender 5.0+ +- Terrain mesh object (default name: "TerrainPlane") + +## Scripts + +### generate_heightmap.py + +Bakes EXR heightmap from terrain mesh using Blender's render system. + +**Output:** `textures/terrain_heightmap.exr` (R32Float single-channel) + +**Usage:** +```python +# In Blender's Scripting workspace - just run the script! +# It will automatically find TerrainPlane and bake to textures/terrain_heightmap.exr +``` + +**Or run from command line:** +```bash +blender terrain.blend --background --python scripts/generate_heightmap.py +``` + +**Custom parameters:** +```python +from generate_heightmap import bake_heightmap + +bake_heightmap( + terrain_obj=bpy.data.objects["TerrainPlane"], + resolution=1000, + output_path="path/to/output.exr" +) +``` + +### generate_normal_map.py + +Generates normal map from terrain mesh for neighbor sampling in shaders. + +**Output:** `textures/terrain_normals.png` (RGB encoded normals) + +**Usage:** +```python +from generate_normal_map import save_normal_map + +save_normal_map( + output_path=project_root / "textures" / "terrain_normals.png", + resolution=1024, + blur_iterations=2, + terrain_name="TerrainPlane" +) +``` + +### generate_flowmap.py + +Generates flowmap for water/snow flow effects. + +**Output:** `textures/terrain_flowmap.png` + +## Terrain Export Workflow + +1. **Model terrain in Blender 5.0** + - Create/sculpt terrain mesh + - Add modifiers (Subdivision, Displacement, etc.) + - Ensure terrain has UV mapping + +2. **Bake heightmap** + - Run `generate_heightmap.py` script + - Uses Blender's baking system (like baking a texture) + - Creates `textures/terrain_heightmap.exr` + - Automatically applies all modifiers + +3. **Export glTF with baked heights** + - Select terrain mesh + - File → Export → glTF 2.0 + - Save as `meshes/terrain.gltf` + - Heights are baked in vertex positions + +4. **Both files in sync** + - glTF: rendering (vertices with baked heights) + - EXR: physics (rapier3d heightfield collider) + - Both from same source = guaranteed match + +## Resolution Guidelines + +- **Heightmap (EXR):** 512×512, 1000×1000, or 1024×1024 + - Higher = more accurate collision + - Lower = faster loading + - Default: 1000×1000 + - Uses Blender's render sampling (no gaps!) + +- **Normal Map:** 1024×1024 or 2048×2048 + - For shader neighbor sampling + - Higher quality for detailed terrain + +## Customization + +Change parameters by editing the script or calling directly: + +```python +from generate_heightmap import bake_heightmap + +bake_heightmap( + terrain_obj=bpy.data.objects["MyTerrain"], + resolution=1024, + output_path="custom/path.exr" +) +``` + +## Output Files + +``` +project_root/ +├── meshes/ +│ └── terrain.gltf # Mesh with baked heights (manual export) +└── textures/ + ├── terrain.exr # Heightmap for physics (generated) + ├── terrain_normals.png # Normal map (generated) + └── terrain_flowmap.png # Flow map (generated) +``` + +## Troubleshooting + +**"Object not found":** +- Ensure terrain object exists +- Check object name matches parameter +- Script will auto-detect objects with "terrain" or "plane" in name + +**"Mesh has no vertices":** +- Apply all modifiers before running script +- Check mesh is not empty + +**EXR export fails:** +- Ensure Blender has EXR support enabled +- Check output directory exists and is writable diff --git a/blender/scripts/generate_heightmap.py b/blender/scripts/generate_heightmap.py new file mode 100644 index 0000000..3223b0c --- /dev/null +++ b/blender/scripts/generate_heightmap.py @@ -0,0 +1,135 @@ +import bpy +from pathlib import Path + + +def bake_heightmap(terrain_obj, resolution=1024, output_path=None): + """ + Bake terrain heightmap using Blender's render/bake system. + + Args: + terrain_obj: Terrain mesh object + resolution: Texture resolution (square) + output_path: Path to save EXR file + """ + + print(f"Baking heightmap for: {terrain_obj.name}") + print(f"Resolution: {resolution}×{resolution}") + + # Ensure object has UV map + if not terrain_obj.data.uv_layers: + print("Adding UV map...") + terrain_obj.data.uv_layers.new(name="UVMap") + + # Create new image for baking + bake_image = bpy.data.images.new( + name="Heightmap_Bake", + width=resolution, + height=resolution, + alpha=False, + float_buffer=True, + is_data=True + ) + + # Setup material for baking + if not terrain_obj.data.materials: + mat = bpy.data.materials.new(name="Heightmap_Material") + terrain_obj.data.materials.append(mat) + else: + mat = terrain_obj.data.materials[0] + + mat.use_nodes = True + nodes = mat.node_tree.nodes + nodes.clear() + + # Create nodes for height baking + # Geometry node to get position + geo_node = nodes.new(type='ShaderNodeNewGeometry') + + # Separate XYZ to get Z (height) + separate_node = nodes.new(type='ShaderNodeSeparateXYZ') + mat.node_tree.links.new(geo_node.outputs['Position'], separate_node.inputs['Vector']) + + # Emission shader to output height value + emission_node = nodes.new(type='ShaderNodeEmission') + mat.node_tree.links.new(separate_node.outputs['Z'], emission_node.inputs['Color']) + + # Material output + output_node = nodes.new(type='ShaderNodeOutputMaterial') + mat.node_tree.links.new(emission_node.outputs['Emission'], output_node.inputs['Surface']) + + # Add image texture node (required for baking target) + image_node = nodes.new(type='ShaderNodeTexImage') + image_node.image = bake_image + image_node.select = True + nodes.active = image_node + + # Select object and set mode + bpy.context.view_layer.objects.active = terrain_obj + terrain_obj.select_set(True) + + # Setup render settings for baking + bpy.context.scene.render.engine = 'CYCLES' + bpy.context.scene.cycles.samples = 1 + bpy.context.scene.cycles.bake_type = 'EMIT' + + print("Baking...") + bpy.ops.object.bake(type='EMIT', use_clear=True) + + print("Bake complete!") + + # Save as EXR + if output_path: + bake_image.filepath_raw = str(output_path) + bake_image.file_format = 'OPEN_EXR' + bake_image.use_half_precision = False + + scene = bpy.context.scene + original_color_mode = scene.render.image_settings.color_mode + original_color_depth = scene.render.image_settings.color_depth + + scene.render.image_settings.color_mode = 'BW' + scene.render.image_settings.color_depth = '32' + + bake_image.save_render(str(output_path), scene=scene) + + scene.render.image_settings.color_mode = original_color_mode + scene.render.image_settings.color_depth = original_color_depth + + print(f"Saved to: {output_path}") + + # Cleanup + bpy.data.images.remove(bake_image) + + return True + + +if __name__ == "__main__": + project_root = Path(bpy.data.filepath).parent.parent + output_path = project_root / "textures" / "terrain_heightmap.exr" + + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Find terrain object + terrain_obj = bpy.data.objects.get("TerrainPlane") + + if not terrain_obj: + print("'TerrainPlane' not found. Searching for terrain mesh...") + for obj in bpy.data.objects: + if obj.type == 'MESH' and ('terrain' in obj.name.lower() or 'plane' in obj.name.lower()): + terrain_obj = obj + print(f"Using: {obj.name}") + break + + if not terrain_obj: + raise ValueError("No terrain object found!") + + bake_heightmap( + terrain_obj=terrain_obj, + resolution=1000, + output_path=output_path + ) + + print("\n" + "="*60) + print("Heightmap baking complete!") + print(f"Output: {output_path}") + print("="*60) diff --git a/blender/scripts/generate_snow_depth.py b/blender/scripts/generate_snow_depth.py new file mode 100644 index 0000000..3253397 --- /dev/null +++ b/blender/scripts/generate_snow_depth.py @@ -0,0 +1,268 @@ +import bpy +from pathlib import Path + + +def find_snow_modifier(terrain_obj): + """ + Find the Geometry Nodes modifier that contains snow_depth attribute. + Returns the modifier or None if not found. + """ + for mod in terrain_obj.modifiers: + if mod.type == 'NODES' and mod.node_group: + # Check if this modifier's node tree has Store Named Attribute with "snow_depth" + for node in mod.node_group.nodes: + if node.type == 'STORE_NAMED_ATTRIBUTE': + if hasattr(node, 'data_type') and node.name and 'snow' in node.name.lower(): + return mod + # Check inputs for the name + for input in node.inputs: + if input.name == 'Name' and hasattr(input, 'default_value'): + if input.default_value == 'snow_depth': + return mod + # Fallback: check modifier name + if 'snow' in mod.name.lower(): + return mod + return None + + +def bake_snow_depth(terrain_obj, resolution=512, output_path=None, modifier_name=None): + """ + Bake snow depth attribute to texture using shader-based Cycles baking. + Uses the same approach as generate_heightmap.py. + + Requires: + - Terrain object with Geometry Nodes modifier that stores 'snow_depth' attribute + - UV map on terrain mesh + + Args: + terrain_obj: Terrain mesh object with snow_depth attribute + resolution: Texture resolution (square) + output_path: Path to save EXR file + modifier_name: Optional specific modifier name to use (e.g., "Snow") + """ + + print(f"Baking snow depth for: {terrain_obj.name}") + print(f"Resolution: {resolution}×{resolution}") + + # Find the snow geometry nodes modifier + if modifier_name: + geo_nodes_modifier = terrain_obj.modifiers.get(modifier_name) + if not geo_nodes_modifier: + raise ValueError(f"Modifier '{modifier_name}' not found on {terrain_obj.name}") + print(f"Using specified modifier: {modifier_name}") + else: + geo_nodes_modifier = find_snow_modifier(terrain_obj) + if not geo_nodes_modifier: + print("\nAvailable Geometry Nodes modifiers:") + for mod in terrain_obj.modifiers: + if mod.type == 'NODES': + print(f" - {mod.name}") + raise ValueError( + f"No Geometry Nodes modifier with 'snow_depth' attribute found on {terrain_obj.name}!\n" + f"Either add snow accumulation modifier, or specify modifier_name parameter." + ) + print(f"Found snow modifier: {geo_nodes_modifier.name}") + + modifier_states = {} + print(f"\nDisabling modifiers after '{geo_nodes_modifier.name}' for baking...") + target_mod_index = list(terrain_obj.modifiers).index(geo_nodes_modifier) + + for i, mod in enumerate(terrain_obj.modifiers): + modifier_states[mod.name] = { + 'show_viewport': mod.show_viewport, + 'show_render': mod.show_render + } + + if i > target_mod_index: + print(f" Temporarily disabling: {mod.name}") + mod.show_viewport = False + mod.show_render = False + + bpy.context.view_layer.update() + depsgraph = bpy.context.evaluated_depsgraph_get() + evaluated_obj = terrain_obj.evaluated_get(depsgraph) + eval_mesh = evaluated_obj.to_mesh() + + if 'snow_depth' not in eval_mesh.attributes: + evaluated_obj.to_mesh_clear() + for mod_name, state in modifier_states.items(): + mod = terrain_obj.modifiers.get(mod_name) + if mod: + mod.show_viewport = state['show_viewport'] + mod.show_render = state['show_render'] + raise ValueError("snow_depth attribute missing from evaluated geometry") + + print(f"✓ Verified 'snow_depth' attribute exists") + evaluated_obj.to_mesh_clear() + + # Ensure object has UV map + if not terrain_obj.data.uv_layers: + print("Adding UV map...") + terrain_obj.data.uv_layers.new(name="UVMap") + + # Create new image for baking + bake_image = bpy.data.images.new( + name="SnowDepth_Bake", + width=resolution, + height=resolution, + alpha=False, + float_buffer=True, + is_data=True + ) + + print(f"Created bake image: {bake_image.name}") + + original_materials = list(terrain_obj.data.materials) + print(f"Object has {len(original_materials)} material slot(s): {[mat.name if mat else 'None' for mat in original_materials]}") + + mat = bpy.data.materials.new(name="SnowDepth_BakeMaterial") + mat.use_nodes = True + nodes = mat.node_tree.nodes + nodes.clear() + + attr_node = nodes.new(type='ShaderNodeAttribute') + attr_node.attribute_name = 'snow_depth' + + emission_node = nodes.new(type='ShaderNodeEmission') + mat.node_tree.links.new(attr_node.outputs['Fac'], emission_node.inputs['Color']) + + output_node = nodes.new(type='ShaderNodeOutputMaterial') + mat.node_tree.links.new(emission_node.outputs['Emission'], output_node.inputs['Surface']) + + image_node = nodes.new(type='ShaderNodeTexImage') + image_node.image = bake_image + image_node.select = True + nodes.active = image_node + + terrain_obj.data.materials.clear() + terrain_obj.data.materials.append(mat) + print(f"Temporarily replaced all materials with bake material") + + # Select object and set mode + bpy.context.view_layer.objects.active = terrain_obj + terrain_obj.select_set(True) + + # Ensure we're in object mode + if bpy.context.object and bpy.context.object.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + + # Setup render settings for baking + bpy.context.scene.render.engine = 'CYCLES' + bpy.context.scene.cycles.samples = 1 + bpy.context.scene.cycles.bake_type = 'EMIT' + + print("Baking with Cycles (EMIT)...") + bpy.ops.object.bake(type='EMIT', use_clear=True) + + print("Bake complete!") + + # Verify bake has data (not all black/zero) + pixels = list(bake_image.pixels) + max_value = max(pixels) if pixels else 0.0 + avg_value = sum(pixels) / len(pixels) if pixels else 0.0 + non_zero_count = sum(1 for p in pixels if p > 0.0001) + print(f"Baked image stats: max={max_value:.4f}, avg={avg_value:.4f}") + print(f"Non-zero pixels: {non_zero_count} ({non_zero_count / len(pixels) * 100:.1f}%)") + + if max_value < 0.0001: + print("\n⚠️ WARNING: Baked image appears to be all black!") + print(" Possible causes:") + print(" - 'snow_depth' attribute doesn't exist in the geometry") + print(" - Geometry Nodes modifier is disabled") + print(" - Store Named Attribute node is not connected") + print(" - Wrong modifier selected (try specifying modifier_name)") + print("\n Continuing anyway, but check your setup...") + else: + print(f"✓ Bake contains data (values up to {max_value:.4f}m)") + + # Save as EXR + if output_path: + bake_image.filepath_raw = str(output_path) + bake_image.file_format = 'OPEN_EXR' + bake_image.use_half_precision = False + + scene = bpy.context.scene + original_color_mode = scene.render.image_settings.color_mode + original_color_depth = scene.render.image_settings.color_depth + original_exr_codec = scene.render.image_settings.exr_codec + + # Use BW mode for single channel (same as heightmap) + scene.render.image_settings.color_mode = 'BW' + scene.render.image_settings.color_depth = '32' + scene.render.image_settings.exr_codec = 'ZIP' + + print(f"Saving EXR with settings: color_mode=BW, depth=32, codec=ZIP") + bake_image.save_render(str(output_path), scene=scene) + + scene.render.image_settings.color_mode = original_color_mode + scene.render.image_settings.color_depth = original_color_depth + scene.render.image_settings.exr_codec = original_exr_codec + + print(f"Saved to: {output_path}") + print(f"Format: OpenEXR, 32-bit float, ZIP compression") + print(f"File size: {output_path.stat().st_size / 1024:.1f} KB") + + bpy.data.images.remove(bake_image) + bpy.data.materials.remove(mat) + + terrain_obj.data.materials.clear() + for original_mat in original_materials: + terrain_obj.data.materials.append(original_mat) + print(f"Restored {len(original_materials)} original material(s)") + + print("\nRestoring modifier states...") + for mod_name, state in modifier_states.items(): + mod = terrain_obj.modifiers.get(mod_name) + if mod: + mod.show_viewport = state['show_viewport'] + mod.show_render = state['show_render'] + print("✓ Modifiers restored") + + return True + + +if __name__ == "__main__": + project_root = Path(bpy.data.filepath).parent.parent + output_path = project_root / "textures" / "snow_depth.exr" + + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Find terrain object + terrain_obj = bpy.data.objects.get("TerrainPlane") + + if not terrain_obj: + print("'TerrainPlane' not found. Searching for terrain mesh...") + for obj in bpy.data.objects: + if obj.type == 'MESH' and ('terrain' in obj.name.lower() or 'plane' in obj.name.lower()): + terrain_obj = obj + print(f"Using: {obj.name}") + break + + if not terrain_obj: + raise ValueError("No terrain object found!") + + # CONFIGURATION: Specify modifier name if you have multiple Geometry Nodes modifiers + # Leave as None to auto-detect the snow modifier + # Example: modifier_name = "Snow" or "Snow Accumulation" + modifier_name = "Snow Accumulation" # Auto-detect by looking for 'snow_depth' attribute + + bake_snow_depth( + terrain_obj=terrain_obj, + resolution=1000, + output_path=output_path, + modifier_name=modifier_name # Specify "Snow" if auto-detect fails + ) + + print("\n" + "="*60) + print("Snow depth baking complete!") + print(f"Output: {output_path}") + print("="*60) + print("\nNext steps:") + print("1. Verify snow_depth.exr in textures/ directory") + print("2. Open in image viewer to check it's not black") + print("3. Load in game with SnowLayer::load()") + print("4. Test deformation with player movement") + print("\nIf bake is black:") + print("- Check that 'snow_depth' attribute exists in Spreadsheet Editor") + print("- Verify Geometry Nodes modifier has Store Named Attribute node") + print("- Try specifying modifier_name='Snow' explicitly in script") diff --git a/blender/terrain.blend b/blender/terrain.blend index b5e3661..b2b495d 100644 Binary files a/blender/terrain.blend and b/blender/terrain.blend differ diff --git a/blender/terrain.blend1 b/blender/terrain.blend1 index afc119f..d9e4968 100644 Binary files a/blender/terrain.blend1 and b/blender/terrain.blend1 differ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..543f09d --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() +{ + let wesl = wesl::Wesl::new("src/shaders"); + wesl.build_artifact(&"package::standard".parse().unwrap(), "standard"); + wesl.build_artifact(&"package::shadow".parse().unwrap(), "shadow"); + wesl.build_artifact(&"package::terrain".parse().unwrap(), "terrain"); +} diff --git a/meshes/terrain.bin b/meshes/terrain.bin index 509bd8f..b6fba5a 100644 Binary files a/meshes/terrain.bin and b/meshes/terrain.bin differ diff --git a/meshes/terrain.glb b/meshes/terrain.glb deleted file mode 100644 index 75392b1..0000000 Binary files a/meshes/terrain.glb and /dev/null differ diff --git a/meshes/terrain.gltf b/meshes/terrain.gltf index 6565f18..1eb0ba2 100644 --- a/meshes/terrain.gltf +++ b/meshes/terrain.gltf @@ -4,8 +4,36 @@ "version":"2.0" }, "extensionsUsed":[ + "KHR_lights_punctual", "EXT_mesh_gpu_instancing" ], + "extensionsRequired":[ + "KHR_lights_punctual" + ], + "extensions":{ + "KHR_lights_punctual":{ + "lights":[ + { + "color":[ + 1, + 1, + 1 + ], + "intensity":543.5141306588226, + "spot":{ + "innerConeAngle":0.18840259313583374, + "outerConeAngle":0.18840259313583374 + }, + "type":"spot", + "range":1000, + "name":"Spot", + "extras":{ + "light_tag":"lighthouse" + } + } + ] + } + }, "scene":0, "scenes":[ { @@ -13,25 +41,33 @@ "nodes":[ 0, 1, - 2 + 2, + 3, + 4 ] } ], "nodes":[ { - "children":[ - 3 - ], - "mesh":1, - "name":"TerrainPlane" - }, - { - "mesh":2, + "mesh":0, "name":"TreePrime", "translation":[ - 0, + 16.22920036315918, + 29.08228302001953, + 39.89393615722656 + ] + }, + { + "children":[ + 5, + 6 + ], + "mesh":3, + "name":"TerrainPlane", + "scale":[ + 1.000100016593933, 1, - 0 + 1 ] }, { @@ -42,36 +78,77 @@ -66.48489379882812 ] }, + { + "name":"PlayerSpawn", + "translation":[ + -351.4849853515625, + 119.54279327392578, + 202.97006225585938 + ] + }, + { + "extensions":{ + "KHR_lights_punctual":{ + "light":0 + } + }, + "name":"Spot", + "rotation":[ + -0.16434744000434875, + -0.37808698415756226, + 0.006467622704803944, + 0.9110424518585205 + ], + "translation":[ + -392.0350036621094, + 238.72787475585938, + 244.30006408691406 + ] + }, { "extensions":{ "EXT_mesh_gpu_instancing":{ "attributes":{ - "TRANSLATION":11, - "ROTATION":12, - "SCALE":13 + "TRANSLATION":17, + "ROTATION":18, + "SCALE":19 } } }, - "mesh":0, + "mesh":1, "name":"TerrainPlane.0" + }, + { + "extensions":{ + "EXT_mesh_gpu_instancing":{ + "attributes":{ + "TRANSLATION":17, + "ROTATION":18, + "SCALE":19 + } + } + }, + "mesh":2, + "name":"TerrainPlane.1" } ], "materials":[ { "doubleSided":true, - "emissiveFactor":[ - 1, - 1, - 1 - ], - "name":"heightmap", + "name":"terrain" + }, + { + "doubleSided":true, + "name":"snow", "pbrMetallicRoughness":{ "baseColorFactor":[ - 0, - 0, - 0, + 0.800000011920929, + 0.800000011920929, + 0.800000011920929, 1 - ] + ], + "metallicFactor":0, + "roughnessFactor":0.5 } } ], @@ -90,7 +167,7 @@ ] }, { - "name":"Plane.001", + "name":"Cylinder", "primitives":[ { "attributes":{ @@ -98,7 +175,7 @@ "NORMAL":5, "TEXCOORD_0":6 }, - "indices":7, + "indices":3, "material":0 } ] @@ -108,11 +185,35 @@ "primitives":[ { "attributes":{ - "POSITION":8, - "NORMAL":9, - "TEXCOORD_0":10 + "POSITION":7, + "NORMAL":8, + "TEXCOORD_0":9 }, - "indices":3 + "indices":3, + "material":1 + } + ] + }, + { + "name":"Plane.001", + "primitives":[ + { + "attributes":{ + "POSITION":10, + "NORMAL":11, + "TEXCOORD_0":12 + }, + "indices":13, + "material":0 + }, + { + "attributes":{ + "POSITION":14, + "NORMAL":15, + "TEXCOORD_0":16 + }, + "indices":13, + "material":1 } ] } @@ -121,204 +222,296 @@ { "bufferView":0, "componentType":5126, - "count":704, + "count":1280, "max":[ - 1, - 11.999963760375977, - 1 + 5.561562538146973, + 16.066009521484375, + 5.561562538146973 ], "min":[ - -1, + -5.561562538146973, 0, - -1 + -5.561562538146973 ], "type":"VEC3" }, { "bufferView":1, "componentType":5126, - "count":704, + "count":1280, "type":"VEC3" }, { "bufferView":2, "componentType":5126, - "count":704, + "count":1280, "type":"VEC2" }, { "bufferView":3, "componentType":5123, - "count":1908, + "count":2100, "type":"SCALAR" }, { "bufferView":4, "componentType":5126, - "count":18196, + "count":1280, "max":[ - 500, - 122.76703643798828, - 500 + 5.561562538146973, + 16.066009521484375, + 5.561562538146973 ], "min":[ - -500, - -0.000225067138671875, - -500 + -5.561562538146973, + 0, + -5.561562538146973 ], "type":"VEC3" }, { "bufferView":5, "componentType":5126, - "count":18196, + "count":1280, "type":"VEC3" }, { "bufferView":6, "componentType":5126, - "count":18196, + "count":1280, "type":"VEC2" }, { "bufferView":7, - "componentType":5123, - "count":61206, - "type":"SCALAR" + "componentType":5126, + "count":1280, + "max":[ + 5.561562538146973, + 16.066009521484375, + 5.561562538146973 + ], + "min":[ + -5.561562538146973, + 0, + -5.561562538146973 + ], + "type":"VEC3" }, { "bufferView":8, "componentType":5126, - "count":704, - "max":[ - 1, - 11.999963760375977, - 1 - ], - "min":[ - -1, - 0, - -1 - ], + "count":1280, "type":"VEC3" }, { "bufferView":9, "componentType":5126, - "count":704, - "type":"VEC3" + "count":1280, + "type":"VEC2" }, { "bufferView":10, "componentType":5126, - "count":704, - "type":"VEC2" + "count":10404, + "max":[ + 500, + 110.45686340332031, + 500 + ], + "min":[ + -500, + -0.9473495483398438, + -500 + ], + "type":"VEC3" }, { "bufferView":11, "componentType":5126, - "count":5588, + "count":10404, "type":"VEC3" }, { "bufferView":12, "componentType":5126, - "count":5588, - "type":"VEC4" + "count":10404, + "type":"VEC2" }, { "bufferView":13, + "componentType":5123, + "count":61206, + "type":"SCALAR" + }, + { + "bufferView":14, "componentType":5126, - "count":5588, + "count":10404, + "max":[ + 500, + 110.7568588256836, + 500 + ], + "min":[ + -500, + -0.6476199626922607, + -500 + ], + "type":"VEC3" + }, + { + "bufferView":15, + "componentType":5126, + "count":10404, + "type":"VEC3" + }, + { + "bufferView":16, + "componentType":5126, + "count":10404, + "type":"VEC2" + }, + { + "bufferView":17, + "componentType":5126, + "count":2380, + "type":"VEC3" + }, + { + "bufferView":18, + "componentType":5126, + "count":2380, + "type":"VEC4" + }, + { + "bufferView":19, + "componentType":5126, + "count":2380, "type":"VEC3" } ], "bufferViews":[ { "buffer":0, - "byteLength":8448, + "byteLength":15360, "byteOffset":0, "target":34962 }, { "buffer":0, - "byteLength":8448, - "byteOffset":8448, + "byteLength":15360, + "byteOffset":15360, "target":34962 }, { "buffer":0, - "byteLength":5632, - "byteOffset":16896, + "byteLength":10240, + "byteOffset":30720, "target":34962 }, { "buffer":0, - "byteLength":3816, - "byteOffset":22528, + "byteLength":4200, + "byteOffset":40960, "target":34963 }, { "buffer":0, - "byteLength":218352, - "byteOffset":26344, + "byteLength":15360, + "byteOffset":45160, "target":34962 }, { "buffer":0, - "byteLength":218352, - "byteOffset":244696, + "byteLength":15360, + "byteOffset":60520, "target":34962 }, { "buffer":0, - "byteLength":145568, - "byteOffset":463048, + "byteLength":10240, + "byteOffset":75880, + "target":34962 + }, + { + "buffer":0, + "byteLength":15360, + "byteOffset":86120, + "target":34962 + }, + { + "buffer":0, + "byteLength":15360, + "byteOffset":101480, + "target":34962 + }, + { + "buffer":0, + "byteLength":10240, + "byteOffset":116840, + "target":34962 + }, + { + "buffer":0, + "byteLength":124848, + "byteOffset":127080, + "target":34962 + }, + { + "buffer":0, + "byteLength":124848, + "byteOffset":251928, + "target":34962 + }, + { + "buffer":0, + "byteLength":83232, + "byteOffset":376776, "target":34962 }, { "buffer":0, "byteLength":122412, - "byteOffset":608616, + "byteOffset":460008, "target":34963 }, { "buffer":0, - "byteLength":8448, - "byteOffset":731028, + "byteLength":124848, + "byteOffset":582420, "target":34962 }, { "buffer":0, - "byteLength":8448, - "byteOffset":739476, + "byteLength":124848, + "byteOffset":707268, "target":34962 }, { "buffer":0, - "byteLength":5632, - "byteOffset":747924, + "byteLength":83232, + "byteOffset":832116, "target":34962 }, { "buffer":0, - "byteLength":67056, - "byteOffset":753556 + "byteLength":28560, + "byteOffset":915348 }, { "buffer":0, - "byteLength":89408, - "byteOffset":820612 + "byteLength":38080, + "byteOffset":943908 }, { "buffer":0, - "byteLength":67056, - "byteOffset":910020 + "byteLength":28560, + "byteOffset":981988 } ], "buffers":[ { - "byteLength":977076, + "byteLength":1010548, "uri":"terrain.bin" } ] diff --git a/shaders/standard.wgsl b/shaders/standard.wgsl deleted file mode 100644 index 2ca99b5..0000000 --- a/shaders/standard.wgsl +++ /dev/null @@ -1,38 +0,0 @@ -@vertex -fn vs_main(input: VertexInput) -> VertexOutput { - var output: VertexOutput; - - let instance_model = mat4x4( - input.instance_model_0, - input.instance_model_1, - input.instance_model_2, - input.instance_model_3 - ); - - let world_pos = instance_model * vec4(input.position, 1.0); - output.world_position = world_pos.xyz; - output.clip_position = uniforms.projection * uniforms.view * world_pos; - - let normal_matrix = mat3x3( - instance_model[0].xyz, - instance_model[1].xyz, - instance_model[2].xyz - ); - output.world_normal = normalize(normal_matrix * input.normal); - - output.light_space_position = uniforms.light_view_projection * world_pos; - - return output; -} - -@fragment -fn fs_main(input: VertexOutput) -> @location(0) vec4 { - let shadow = sample_shadow_map(input.light_space_position); - - let tile_scale = 1.0; - let flowmap_strokes = flowmap_path_lighting_with_shadow(input.world_position, input.world_normal, tile_scale, shadow); - let point_strokes = point_lighting_with_shadow(input.world_position, input.world_normal, vec3(0.0, 100.0, 0.0), tile_scale, shadow); - let brightness = max(flowmap_strokes, point_strokes); - - return vec4(brightness, brightness, brightness, 1.0); -} diff --git a/src/camera.rs b/src/camera.rs index 7bfee34..23e1208 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -1,6 +1,11 @@ use bytemuck::{Pod, Zeroable}; use glam::{Mat4, Vec3}; +use crate::components::CameraComponent; +use crate::entity::EntityHandle; +use crate::render; +use crate::world::{Transform, World}; + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] pub struct CameraUniforms @@ -26,147 +31,21 @@ impl CameraUniforms } } -pub struct Camera -{ - pub position: Vec3, - pub target: Vec3, - pub up: Vec3, - pub fov: f32, - pub aspect: f32, - pub near: f32, - pub far: f32, - pub yaw: f32, - pub pitch: f32, - pub is_following: bool, - pub follow_offset: Vec3, -} +pub struct Camera; impl Camera { - pub fn init(aspect: f32) -> Self + pub fn spawn(world: &mut World, position: Vec3) -> EntityHandle { - Self { - position: Vec3::new(15.0, 15.0, 15.0), - target: Vec3::ZERO, - up: Vec3::Y, - fov: 45.0_f32.to_radians(), - aspect, - near: 0.1, - far: 100.0, - yaw: -135.0_f32.to_radians(), - pitch: -30.0_f32.to_radians(), - is_following: true, - follow_offset: Vec3::ZERO, - } - } + let camera_entity = world.spawn(); - pub fn view_matrix(&self) -> Mat4 - { - Mat4::look_at_rh(self.position, self.target, self.up) - } + let camera_component = CameraComponent::new(render::aspect_ratio()); - pub fn projection_matrix(&self) -> Mat4 - { - Mat4::perspective_rh(self.fov, self.aspect, self.near, self.far) - } + let transform = Transform::from_position(position); - pub fn update_rotation(&mut self, mouse_delta: (f32, f32), sensitivity: f32) - { - self.yaw += mouse_delta.0 * sensitivity; - self.pitch -= mouse_delta.1 * sensitivity; + world.cameras.insert(camera_entity, camera_component); + world.transforms.insert(camera_entity, transform); - self.pitch = self - .pitch - .clamp(-89.0_f32.to_radians(), 89.0_f32.to_radians()); - } - - pub fn get_forward(&self) -> Vec3 - { - Vec3::new( - self.yaw.cos() * self.pitch.cos(), - self.pitch.sin(), - self.yaw.sin() * self.pitch.cos(), - ) - .normalize() - } - - pub fn get_right(&self) -> Vec3 - { - self.get_forward().cross(Vec3::Y).normalize() - } - - pub fn get_forward_horizontal(&self) -> Vec3 - { - Vec3::new(self.yaw.cos(), 0.0, self.yaw.sin()).normalize() - } - - pub fn get_right_horizontal(&self) -> Vec3 - { - self.get_forward_horizontal().cross(Vec3::Y).normalize() - } - - pub fn update_noclip(&mut self, input: Vec3, speed: f32) - { - let forward = self.get_forward(); - let right = self.get_right(); - - self.position += forward * input.z * speed; - self.position += right * input.x * speed; - self.position += Vec3::Y * input.y * speed; - - self.target = self.position + forward; - } - - pub fn start_following(&mut self, target_position: Vec3) - { - self.is_following = true; - self.follow_offset = self.position - target_position; - - let distance = self.follow_offset.length(); - if distance > 0.0 - { - self.pitch = (self.follow_offset.y / distance).asin(); - self.yaw = self.follow_offset.z.atan2(self.follow_offset.x) + std::f32::consts::PI; - } - } - - pub fn stop_following(&mut self) - { - self.is_following = false; - - let look_direction = (self.target - self.position).normalize(); - - self.yaw = look_direction.z.atan2(look_direction.x); - self.pitch = look_direction.y.asin(); - } - - pub fn update_follow(&mut self, target_position: Vec3, mouse_delta: (f32, f32), sensitivity: f32) - { - if !self.is_following - { - return; - } - - if mouse_delta.0.abs() > 0.0 || mouse_delta.1.abs() > 0.0 - { - self.yaw += mouse_delta.0 * sensitivity; - self.pitch += mouse_delta.1 * sensitivity; - - self.pitch = self - .pitch - .clamp(-89.0_f32.to_radians(), 89.0_f32.to_radians()); - } - - let distance = self.follow_offset.length(); - - let orbit_yaw = self.yaw + std::f32::consts::PI; - - let offset_x = distance * orbit_yaw.cos() * self.pitch.cos(); - let offset_y = distance * self.pitch.sin(); - let offset_z = distance * orbit_yaw.sin() * self.pitch.cos(); - - self.follow_offset = Vec3::new(offset_x, offset_y, offset_z); - self.position = target_position + self.follow_offset; - self.target = target_position; + camera_entity } } diff --git a/src/components/camera_follow.rs b/src/components/camera_follow.rs deleted file mode 100644 index f081d26..0000000 --- a/src/components/camera_follow.rs +++ /dev/null @@ -1,32 +0,0 @@ -use glam::Vec3; - -use crate::entity::EntityHandle; - -#[derive(Clone, Copy)] -pub struct CameraFollowComponent -{ - pub target_entity: EntityHandle, - pub offset: Vec3, - pub is_following: bool, -} - -impl CameraFollowComponent -{ - pub fn new(target_entity: EntityHandle) -> Self - { - Self { - target_entity, - offset: Vec3::ZERO, - is_following: false, - } - } - - pub fn with_offset(target_entity: EntityHandle, offset: Vec3) -> Self - { - Self { - target_entity, - offset, - is_following: true, - } - } -} diff --git a/src/components/dissolve.rs b/src/components/dissolve.rs new file mode 100644 index 0000000..b1c70c5 --- /dev/null +++ b/src/components/dissolve.rs @@ -0,0 +1,27 @@ +pub struct DissolveComponent +{ + pub amount: f32, + pub target_amount: f32, + pub transition_speed: f32, +} + +impl DissolveComponent +{ + pub fn new() -> Self + { + Self { + amount: 0.0, + target_amount: 0.0, + transition_speed: 3.0, + } + } + + pub fn with_speed(transition_speed: f32) -> Self + { + Self { + amount: 0.0, + target_amount: 0.0, + transition_speed, + } + } +} diff --git a/src/components/follow.rs b/src/components/follow.rs new file mode 100644 index 0000000..6cbcb02 --- /dev/null +++ b/src/components/follow.rs @@ -0,0 +1,10 @@ +use crate::entity::EntityHandle; +use crate::utility::transform::Transform; + +pub struct FollowComponent +{ + pub target: EntityHandle, + pub offset: Transform, + pub inherit_rotation: bool, + pub inherit_scale: bool, +} diff --git a/src/components/jump.rs b/src/components/jump.rs index f07788f..2ad2b5c 100644 --- a/src/components/jump.rs +++ b/src/components/jump.rs @@ -41,12 +41,7 @@ impl Default for JumpConfig max_air_momentum: 8.0, air_damping_active: 0.4, air_damping_passive: 0.9, - jump_curve: CubicBez::new( - (0.0, 0.0), - (0.4, 0.75), - (0.7, 0.9), - (1.0, 1.0), - ), + jump_curve: CubicBez::new((0.0, 0.0), (0.4, 0.75), (0.7, 0.9), (1.0, 1.0)), jump_context: JumpContext::default(), } } diff --git a/src/components/lights/directional.rs b/src/components/lights/directional.rs new file mode 100644 index 0000000..27c391e --- /dev/null +++ b/src/components/lights/directional.rs @@ -0,0 +1,18 @@ +use glam::Vec3; + +pub struct DirectionallightComponent +{ + pub offset: Vec3, + pub direction: Vec3, +} + +impl DirectionallightComponent +{ + pub fn new(offset: Vec3, direction: Vec3) -> Self + { + Self { + offset, + direction: direction.normalize(), + } + } +} diff --git a/src/components/lights/mod.rs b/src/components/lights/mod.rs new file mode 100644 index 0000000..dad9654 --- /dev/null +++ b/src/components/lights/mod.rs @@ -0,0 +1,3 @@ +pub mod directional; +pub mod point; +pub mod spot; diff --git a/src/components/lights/point.rs b/src/components/lights/point.rs new file mode 100644 index 0000000..95fa91c --- /dev/null +++ b/src/components/lights/point.rs @@ -0,0 +1,14 @@ +use glam::Vec3; + +pub struct PointlightComponent +{ + pub offset: Vec3, +} + +impl PointlightComponent +{ + pub fn new(offset: Vec3) -> Self + { + Self { offset } + } +} diff --git a/src/components/lights/spot.rs b/src/components/lights/spot.rs new file mode 100644 index 0000000..3960f7c --- /dev/null +++ b/src/components/lights/spot.rs @@ -0,0 +1,25 @@ +use glam::Vec3; + +#[derive(Debug, Copy, Clone)] +pub struct SpotlightComponent +{ + pub offset: Vec3, + pub direction: Vec3, + pub range: f32, + pub inner_angle: f32, + pub outer_angle: f32, +} + +impl SpotlightComponent +{ + pub fn new(offset: Vec3, direction: Vec3, range: f32, inner_angle: f32, outer_angle: f32) -> Self + { + Self { + offset, + direction: direction.normalize(), + range, + inner_angle, + outer_angle, + } + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index c61dbb1..d263049 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,16 +1,22 @@ pub mod camera; -pub mod camera_follow; +pub mod dissolve; +pub mod follow; pub mod input; pub mod jump; +pub mod lights; pub mod mesh; pub mod movement; pub mod physics; pub mod player_tag; +pub mod rotate; pub mod state_machine; +pub mod tree_tag; pub use camera::CameraComponent; -pub use camera_follow::CameraFollowComponent; +pub use dissolve::DissolveComponent; +pub use follow::FollowComponent; pub use input::InputComponent; pub use mesh::MeshComponent; pub use movement::MovementComponent; pub use physics::PhysicsComponent; +pub use rotate::RotateComponent; diff --git a/src/components/movement.rs b/src/components/movement.rs index ab17df3..0adcc89 100644 --- a/src/components/movement.rs +++ b/src/components/movement.rs @@ -43,7 +43,7 @@ impl MovementConfig (1.0, 1.0), ), walking_damping: 0.8, - max_walking_speed: 6.0, + max_walking_speed: 12.0, idle_damping: 0.1, movement_context: MovementContext::new(), } diff --git a/src/components/rotate.rs b/src/components/rotate.rs new file mode 100644 index 0000000..1bba74f --- /dev/null +++ b/src/components/rotate.rs @@ -0,0 +1,15 @@ +use glam::Vec3; + +pub struct RotateComponent +{ + pub axis: Vec3, + pub speed: f32, +} + +impl RotateComponent +{ + pub fn new(axis: Vec3, speed: f32) -> Self + { + Self { axis, speed } + } +} diff --git a/src/components/tree_tag.rs b/src/components/tree_tag.rs new file mode 100644 index 0000000..c25a7b9 --- /dev/null +++ b/src/components/tree_tag.rs @@ -0,0 +1 @@ +pub struct TreeTag; diff --git a/src/debug/collider_debug.rs b/src/debug/collider_debug.rs index 9006651..8273dab 100644 --- a/src/debug/collider_debug.rs +++ b/src/debug/collider_debug.rs @@ -126,6 +126,8 @@ pub fn render_collider_debug() -> Vec let instance_data = InstanceRaw { model: model.to_cols_array_2d(), + dissolve_amount: 0.0, + _padding: [0.0; 3], }; let instance_buffer = render::with_device(|device| { @@ -153,6 +155,8 @@ pub fn render_collider_debug() -> Vec { let instance_data = InstanceRaw { model: Mat4::IDENTITY.to_cols_array_2d(), + dissolve_amount: 0.0, + _padding: [0.0; 3], }; let instance_buffer = render::with_device(|device| { diff --git a/src/debug/mod.rs b/src/debug/mod.rs index c7e4bce..1ca8372 100644 --- a/src/debug/mod.rs +++ b/src/debug/mod.rs @@ -1,5 +1,3 @@ pub mod collider_debug; -pub mod noclip; -pub use collider_debug::{render_collider_debug, set_debug_heightfield}; -pub use noclip::{update_follow_camera, update_noclip_camera}; +pub use collider_debug::render_collider_debug; diff --git a/src/empty.rs b/src/empty.rs new file mode 100644 index 0000000..34eb0fc --- /dev/null +++ b/src/empty.rs @@ -0,0 +1,101 @@ +use glam::Mat4; +use std::path::Path; + +pub struct EmptyNode +{ + pub name: String, + pub transform: Mat4, +} + +impl EmptyNode +{ + pub fn new(name: String, transform: Mat4) -> Self + { + Self { name, transform } + } +} + +pub struct Empties +{ + nodes: Vec, +} + +impl Empties +{ + fn new(nodes: Vec) -> Self + { + Self { nodes } + } + + pub fn into_nodes(self) -> Vec + { + self.nodes + } + + pub fn load_gltf_empties(path: impl AsRef) + -> Result> + { + let (gltf, _buffers, _images) = gltf::import(path)?; + + let mut all_empties = Vec::new(); + + for scene in gltf.scenes() + { + for node in scene.nodes() + { + Self::process_node(&node, Mat4::IDENTITY, &mut all_empties)?; + } + } + + Ok(Empties::new(all_empties)) + } + + fn process_node( + node: &gltf::Node, + parent_transform: Mat4, + all_empties: &mut Vec, + ) -> Result<(), Box> + { + let local_transform = Mat4::from_cols_array_2d(&node.transform().matrix()); + let global_transform = parent_transform * local_transform; + + let is_empty = node.mesh().is_none() && node.light().is_none() && node.camera().is_none(); + + if is_empty + { + let name = node.name().unwrap_or("Unnamed").to_string(); + all_empties.push(EmptyNode::new(name, global_transform)); + } + + for child in node.children() + { + Self::process_node(&child, global_transform, all_empties)?; + } + + Ok(()) + } + + pub fn load_empties(path: impl AsRef) -> Result> + { + Self::load_gltf_empties(path) + } + + pub fn get_empty_by_name( + gltf_path: &str, + name: &str, + ) -> anyhow::Result> + { + let empties = Self::load_empties(gltf_path) + .map_err(|e| anyhow::anyhow!("Failed to load empty nodes: {}", e))?; + + for empty_node in empties.into_nodes() + { + if empty_node.name == name + { + return Ok(Some(empty_node)); + } + } + + Ok(None) + } +} diff --git a/src/light.rs b/src/light.rs new file mode 100644 index 0000000..4c9399f --- /dev/null +++ b/src/light.rs @@ -0,0 +1,153 @@ +use glam::{Mat4, Vec3}; +use gltf::json::Extras; +use std::{ops::Deref, path::Path}; + +use crate::{ + components::lights::spot::SpotlightComponent, + world::{Transform, World}, +}; + +pub struct LightData +{ + pub component: SpotlightComponent, + pub transform: Mat4, + pub tag: Option, +} + +pub struct Lights +{ + spotlights: Vec, +} + +impl Lights +{ + fn new(spotlights: Vec) -> Self + { + Self { spotlights } + } + + pub fn into_spotlights(self) -> Vec + { + self.spotlights + } + + pub fn load_gltf_lights(path: impl AsRef) -> Result> + { + let (gltf, _buffers, _images) = gltf::import(path)?; + + let mut all_directional = Vec::new(); + let mut all_point = Vec::new(); + let mut all_spot = Vec::new(); + + for scene in gltf.scenes() + { + for node in scene.nodes() + { + Self::process_node( + &node, + Mat4::IDENTITY, + &mut all_directional, + &mut all_point, + &mut all_spot, + )?; + } + } + + Ok(Lights::new(all_spot)) + } + + fn process_node( + node: &gltf::Node, + parent_transform: Mat4, + all_directional: &mut Vec, + all_point: &mut Vec, + all_spot: &mut Vec, + ) -> Result<(), Box> + { + let local_transform = Mat4::from_cols_array_2d(&node.transform().matrix()); + let global_transform = parent_transform * local_transform; + + if let Some(light) = node.light() + { + let local_transform = Mat4::from_cols_array_2d(&node.transform().matrix()); + let global_transform = parent_transform * local_transform; + let (_scale, rotation, _translation) = global_transform.to_scale_rotation_translation(); + + let tag = serde_json::to_value(light.extras()) + .ok() + .and_then(|extras| { + extras + .get("light_tag") + .and_then(|v| v.as_str()) + .map(String::from) + }); + + match light.kind() + { + gltf::khr_lights_punctual::Kind::Directional => todo!(), + gltf::khr_lights_punctual::Kind::Point => todo!(), + gltf::khr_lights_punctual::Kind::Spot { + inner_cone_angle, + outer_cone_angle, + } => + { + let range = light.range().unwrap_or(100.0); + let spotlight = SpotlightComponent::new( + Vec3::ZERO, + rotation * -Vec3::Z, + range, + inner_cone_angle, + outer_cone_angle, + ); + all_spot.push(LightData { + component: spotlight, + transform: global_transform, + tag, + }); + }, + } + } + + for child in node.children() + { + Self::process_node( + &child, + global_transform, + all_directional, + all_point, + all_spot, + )?; + } + + Ok(()) + } + + pub fn load_lights(path: impl AsRef) -> Result> + { + crate::render::with_device(|_device| Lights::load_gltf_lights(path)) + } + + pub fn spawn_lights(world: &mut World, spotlights: Vec) + { + use crate::components::RotateComponent; + + for light_data in spotlights + { + let entity = world.spawn(); + let transform = Transform::from_matrix(light_data.transform); + + world.transforms.insert(entity, transform); + world.spotlights.insert(entity, light_data.component); + + if let Some(tag) = light_data.tag + { + if tag == "lighthouse" + { + world + .rotates + .insert(entity, RotateComponent::new(Vec3::Y, 1.0)); + } + } + } + } +} diff --git a/src/main.rs b/src/main.rs index 087ab2d..77b4251 100755 --- a/src/main.rs +++ b/src/main.rs @@ -2,20 +2,24 @@ mod camera; mod components; mod debug; mod draw; +mod empty; mod entity; mod event; mod heightmap; +mod light; mod mesh; mod physics; -mod picking; mod player; mod postprocess; mod render; mod shader; +mod snow; +mod snow_light; +mod space; mod state; mod systems; mod terrain; -mod texture_loader; +mod texture; mod utility; mod world; @@ -26,16 +30,21 @@ use render::Renderer; use utility::input::InputState; use world::{Transform, World}; -use crate::components::{CameraComponent, CameraFollowComponent}; +use crate::camera::Camera; +use crate::components::CameraComponent; use crate::debug::render_collider_debug; use crate::entity::EntityHandle; +use crate::light::Lights; use crate::physics::PhysicsManager; use crate::player::Player; +use crate::space::Space; use crate::systems::{ camera_follow_system, camera_input_system, camera_noclip_system, physics_sync_system, - player_input_system, render_system, start_camera_following, state_machine_physics_system, - state_machine_system, stop_camera_following, + player_input_system, render_system, rotate_system, spotlight_sync_system, + start_camera_following, state_machine_physics_system, state_machine_system, + stop_camera_following, }; +use crate::snow::{SnowConfig, SnowLayer}; use crate::terrain::{Terrain, TerrainConfig}; use crate::utility::time::Time; @@ -45,26 +54,43 @@ fn main() -> Result<(), Box> let video_subsystem = sdl_context.video()?; let window = video_subsystem - .window("snow_trail", 800, 600) + .window("snow_trail", 1200, 900) .position_centered() .resizable() .vulkan() .build()?; - - let renderer = pollster::block_on(Renderer::new(&window, 1))?; + let renderer = pollster::block_on(Renderer::new(&window, 2))?; render::init(renderer); + let space = Space::load_space("meshes/terrain.gltf")?; let terrain_config = TerrainConfig::default(); + let player_spawn = space.player_spawn; + let camera_spawn = space.camera_spawn_position(); + let mut world = World::new(); - let player_entity = Player::spawn(&mut world); - let _terrain_entity = Terrain::spawn(&mut world, &terrain_config)?; + + let _player_entity = Player::spawn(&mut world, player_spawn); + let _terrain_entity = Terrain::spawn(&mut world, space.mesh_data, &terrain_config)?; + Lights::spawn_lights(&mut world, space.spotlights); render::set_terrain_data(); + let terrain_half_size = terrain_config.size / 2.0; + render::init_snow_light_accumulation( + glam::Vec2::new(-terrain_half_size.x, -terrain_half_size.y), + glam::Vec2::new(terrain_half_size.x, terrain_half_size.y), + ); + + let snow_config = SnowConfig::default(); + let snow_layer = SnowLayer::load(&mut world, &snow_config)?; + println!("Snow layer loaded successfully"); + + render::set_snow_depth(&snow_layer.depth_texture_view); + let mut noclip_mode = true; - let camera_entity = spawn_camera(&mut world, player_entity); + let camera_entity = Camera::spawn(&mut world, camera_spawn); if noclip_mode == false { start_camera_following(&mut world, camera_entity); @@ -150,6 +176,11 @@ fn main() -> Result<(), Box> state_machine_system(&mut world, delta); + rotate_system(&mut world, delta); + + let spotlights = spotlight_sync_system(&world); + render::update_spotlights(spotlights); + let mut draw_calls = render_system(&world); draw_calls.extend(render_collider_debug()); @@ -161,12 +192,22 @@ fn main() -> Result<(), Box> get_view_matrix(&world, camera_entity, camera_transform, camera_component); let projection = camera_component.projection_matrix(); - render::render_with_matrices( + let player_pos = world + .player_tags + .all() + .first() + .and_then(|e| world.transforms.get(*e)) + .map(|t| t.position) + .unwrap_or(Vec3::ZERO); + + render::render( &view, &projection, camera_transform.position, + player_pos, &draw_calls, time, + delta, ); } } @@ -183,27 +224,6 @@ fn main() -> Result<(), Box> Ok(()) } -fn spawn_camera(world: &mut World, target_entity: EntityHandle) -> EntityHandle -{ - let camera_entity = world.spawn(); - - let camera_component = CameraComponent::new(render::aspect_ratio()); - let camera_follow = CameraFollowComponent::new(target_entity); - - let initial_position = Vec3::new(15.0, 15.0, 15.0); - let transform = Transform { - position: initial_position, - rotation: glam::Quat::IDENTITY, - scale: Vec3::ONE, - }; - - world.cameras.insert(camera_entity, camera_component); - world.camera_follows.insert(camera_entity, camera_follow); - world.transforms.insert(camera_entity, transform); - - camera_entity -} - fn get_view_matrix( world: &World, camera_entity: EntityHandle, @@ -211,18 +231,15 @@ fn get_view_matrix( camera_component: &CameraComponent, ) -> glam::Mat4 { - if let Some(follow) = world.camera_follows.get(camera_entity) + if let Some(follow) = world.follows.get(camera_entity) { - if follow.is_following + if let Some(target_transform) = world.transforms.get(follow.target) { - if let Some(target_transform) = world.transforms.get(follow.target_entity) - { - return glam::Mat4::look_at_rh( - camera_transform.position, - target_transform.position, - Vec3::Y, - ); - } + return glam::Mat4::look_at_rh( + camera_transform.position, + target_transform.position, + Vec3::Y, + ); } } diff --git a/src/mesh.rs b/src/mesh.rs index acdc69a..26fb8ac 100644 --- a/src/mesh.rs +++ b/src/mesh.rs @@ -48,6 +48,7 @@ pub struct InstanceData pub position: Vec3, pub rotation: Quat, pub scale: Vec3, + pub dissolve_amount: f32, } impl InstanceData @@ -57,6 +58,8 @@ impl InstanceData let model = Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.position); InstanceRaw { model: model.to_cols_array_2d(), + dissolve_amount: self.dissolve_amount, + _padding: [0.0; 3], } } } @@ -66,6 +69,8 @@ impl InstanceData pub struct InstanceRaw { pub model: [[f32; 4]; 4], + pub dissolve_amount: f32, + pub _padding: [f32; 3], } impl InstanceRaw @@ -96,6 +101,11 @@ impl InstanceRaw shader_location: 6, format: wgpu::VertexFormat::Float32x4, }, + wgpu::VertexAttribute { + offset: (std::mem::size_of::<[f32; 4]>() * 4) as wgpu::BufferAddress, + shader_location: 7, + format: wgpu::VertexFormat::Float32, + }, ], } } @@ -506,88 +516,92 @@ impl Mesh { let extensions = json_node.get("extensions").unwrap(); let instancing_ext = extensions.get("EXT_mesh_gpu_instancing").unwrap(); - let mut mesh_vertices = Vec::new(); - let mut mesh_indices = Vec::new(); + let mut mesh_vertices = Vec::new(); + let mut mesh_indices = Vec::new(); - for primitive in mesh_data.primitives() + for primitive in mesh_data.primitives() + { + let reader = primitive + .reader(|buffer| buffers.get(buffer.index()).map(|data| &data[..])); + + let positions = reader + .read_positions() + .ok_or_else(|| anyhow::anyhow!("Missing position data"))? + .collect::>(); + + let normals = reader + .read_normals() + .ok_or_else(|| anyhow::anyhow!("Missing normal data"))? + .collect::>(); + + let uvs = reader + .read_tex_coords(0) + .map(|iter| iter.into_f32().collect::>()) + .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); + + let base_index = mesh_vertices.len() as u32; + + for ((pos, normal), uv) in + positions.iter().zip(normals.iter()).zip(uvs.iter()) { - let reader = primitive - .reader(|buffer| buffers.get(buffer.index()).map(|data| &data[..])); - - let positions = reader - .read_positions() - .ok_or_else(|| anyhow::anyhow!("Missing position data"))? - .collect::>(); - - let normals = reader - .read_normals() - .ok_or_else(|| anyhow::anyhow!("Missing normal data"))? - .collect::>(); - - let uvs = reader - .read_tex_coords(0) - .map(|iter| iter.into_f32().collect::>()) - .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]); - - let base_index = mesh_vertices.len() as u32; - - for ((pos, normal), uv) in - positions.iter().zip(normals.iter()).zip(uvs.iter()) - { - mesh_vertices.push(Vertex { - position: *pos, - normal: *normal, - uv: *uv, - }); - } - - if let Some(indices_reader) = reader.read_indices() - { - mesh_indices - .extend(indices_reader.into_u32().map(|i| i + base_index)); - } + mesh_vertices.push(Vertex { + position: *pos, + normal: *normal, + uv: *uv, + }); } - let attributes = instancing_ext - .get("attributes") - .and_then(|v| v.as_object()) - .ok_or_else(|| anyhow::anyhow!("Missing attributes in EXT_mesh_gpu_instancing"))?; + if let Some(indices_reader) = reader.read_indices() + { + mesh_indices.extend(indices_reader.into_u32().map(|i| i + base_index)); + } + } - let translation_accessor_index = attributes - .get("TRANSLATION") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Missing TRANSLATION in instancing extension"))? as usize; + let attributes = instancing_ext + .get("attributes") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + anyhow::anyhow!("Missing attributes in EXT_mesh_gpu_instancing") + })?; - let rotation_accessor_index = attributes - .get("ROTATION") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Missing ROTATION in instancing extension"))? as usize; + let translation_accessor_index = attributes + .get("TRANSLATION") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + anyhow::anyhow!("Missing TRANSLATION in instancing extension") + })? as usize; - let scale_accessor_index = attributes - .get("SCALE") - .and_then(|v| v.as_u64()) - .ok_or_else(|| anyhow::anyhow!("Missing SCALE in instancing extension"))? as usize; + let rotation_accessor_index = attributes + .get("ROTATION") + .and_then(|v| v.as_u64()) + .ok_or_else(|| { + anyhow::anyhow!("Missing ROTATION in instancing extension") + })? as usize; - let translations = Self::read_vec3_accessor( - &document, - &buffers, - translation_accessor_index, - )?; - let rotations = - Self::read_quat_accessor(&document, &buffers, rotation_accessor_index)?; - let scales = - Self::read_vec3_accessor(&document, &buffers, scale_accessor_index)?; + let scale_accessor_index = attributes + .get("SCALE") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow::anyhow!("Missing SCALE in instancing extension"))? + as usize; - let instances: Vec = translations - .into_iter() - .zip(rotations.into_iter()) - .zip(scales.into_iter()) - .map(|((position, rotation), scale)| InstanceData { - position, - rotation, - scale, - }) - .collect(); + let translations = + Self::read_vec3_accessor(&document, &buffers, translation_accessor_index)?; + let rotations = + Self::read_quat_accessor(&document, &buffers, rotation_accessor_index)?; + let scales = + Self::read_vec3_accessor(&document, &buffers, scale_accessor_index)?; + + let instances: Vec = translations + .into_iter() + .zip(rotations.into_iter()) + .zip(scales.into_iter()) + .map(|((position, rotation), scale)| InstanceData { + position, + rotation, + scale, + dissolve_amount: 0.0, + }) + .collect(); let mesh = Mesh::new(device, &mesh_vertices, &mesh_indices); result.push((mesh, instances)); @@ -655,7 +669,9 @@ impl Mesh .nth(accessor_index) .ok_or_else(|| anyhow::anyhow!("Invalid accessor index"))?; - let buffer_view = accessor.view().ok_or_else(|| anyhow::anyhow!("Missing buffer view"))?; + let buffer_view = accessor + .view() + .ok_or_else(|| anyhow::anyhow!("Missing buffer view"))?; let buffer = &buffers[buffer_view.buffer().index()]; let start = buffer_view.offset() + accessor.offset(); let stride = buffer_view.stride().unwrap_or(12); @@ -699,7 +715,9 @@ impl Mesh .nth(accessor_index) .ok_or_else(|| anyhow::anyhow!("Invalid accessor index"))?; - let buffer_view = accessor.view().ok_or_else(|| anyhow::anyhow!("Missing buffer view"))?; + let buffer_view = accessor + .view() + .ok_or_else(|| anyhow::anyhow!("Missing buffer view"))?; let buffer = &buffers[buffer_view.buffer().index()]; let start = buffer_view.offset() + accessor.offset(); let stride = buffer_view.stride().unwrap_or(16); diff --git a/src/player.rs b/src/player.rs index cd04e94..a70753a 100644 --- a/src/player.rs +++ b/src/player.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{f32::consts::PI, rc::Rc}; use glam::Vec3; use kurbo::ParamCurve; @@ -10,7 +10,8 @@ use rapier3d::{ use crate::{ components::{ - jump::JumpComponent, InputComponent, MeshComponent, MovementComponent, PhysicsComponent, + jump::JumpComponent, lights::spot::SpotlightComponent, InputComponent, MeshComponent, + MovementComponent, PhysicsComponent, }, entity::EntityHandle, mesh::Mesh, @@ -24,14 +25,14 @@ pub struct Player; impl Player { - pub fn spawn(world: &mut World) -> EntityHandle + pub fn spawn(world: &mut World, position: Vec3) -> EntityHandle { let entity = world.spawn(); - let initial_position = Vec3::new(0.0, 5.0, 0.0); + let spawn_transform = Transform::from_position(position); let rigidbody = RigidBodyBuilder::kinematic_position_based() - .translation(initial_position.into()) + .translation(spawn_transform.position.into()) .build(); let collider = ColliderBuilder::capsule_y(0.5, 0.5).build(); let _controller = KinematicCharacterController { @@ -163,7 +164,7 @@ impl Player world .transforms - .insert(entity, Transform::from_position(initial_position)); + .insert(entity, spawn_transform); world.movements.insert(entity, MovementComponent::new()); world.jumps.insert(entity, JumpComponent::new()); world.inputs.insert(entity, InputComponent::default()); @@ -178,7 +179,7 @@ impl Player entity, MeshComponent { mesh: Rc::new(mesh), - pipeline: Pipeline::Render, + pipeline: Pipeline::Standard, instance_buffer: None, num_instances: 1, }, @@ -186,6 +187,18 @@ impl Player world.player_tags.insert(entity); world.state_machines.insert(entity, state_machine); + let outer_angle = PI / 2.0 * 0.9; + world.spotlights.insert( + entity, + SpotlightComponent::new( + Vec3::new(1.0, 2.0, 1.0), + Vec3::new(0.0, -1.0, 0.0), + 100.0, + outer_angle * 0.5, + outer_angle, + ), + ); + entity } @@ -233,7 +246,8 @@ impl State for PlayerFallingState .flatten() .unwrap(); - let terrain_height = PhysicsManager::get_terrain_height_at(current_pos.x, current_pos.z); + let next_pos = current_pos + velocity; + let terrain_height = PhysicsManager::get_terrain_height_at(next_pos.x, next_pos.z); let is_grounded = if let Some(height) = terrain_height { @@ -553,7 +567,8 @@ impl State for PlayerJumpingState let current_time = Time::get_time_elapsed(); world.jumps.with_mut(self.entity, |jump| { - jump.jump_config.jump_context.duration = current_time - jump.jump_config.jump_context.execution_time; + jump.jump_config.jump_context.duration = + current_time - jump.jump_config.jump_context.execution_time; }); let jump_config = world diff --git a/src/postprocess.rs b/src/postprocess.rs index 4d614df..65109e5 100644 --- a/src/postprocess.rs +++ b/src/postprocess.rs @@ -147,7 +147,7 @@ pub fn create_blit_pipeline( ) -> wgpu::RenderPipeline { let shader_source = - std::fs::read_to_string("shaders/blit.wgsl").expect("Failed to read blit shader"); + std::fs::read_to_string("src/shaders/blit.wgsl").expect("Failed to read blit shader"); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Blit Shader"), diff --git a/src/render.rs b/src/render.rs index 74c1132..dfd26ea 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,14 +1,67 @@ -use crate::camera::{Camera, CameraUniforms}; -use crate::mesh::Mesh; +use crate::camera::CameraUniforms; use crate::postprocess::{create_blit_pipeline, create_fullscreen_quad, LowResFramebuffer}; -use crate::shader::create_render_pipeline; +use crate::shader::{create_environment_pipeline, create_render_pipeline}; use crate::terrain::create_terrain_render_pipeline; -use crate::texture_loader::{DitherTextures, FlowmapTexture}; -use crate::utility::transform::Transform; +use crate::texture::{DitherTextures, FlowmapTexture}; use bytemuck::{Pod, Zeroable}; use glam::Mat4; use std::cell::RefCell; -use std::rc::Rc; +use wesl::Wesl; + +pub const MAX_SPOTLIGHTS: usize = 4; + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable, Default)] +pub struct SpotlightRaw +{ + pub position: [f32; 3], + pub inner_angle: f32, + pub direction: [f32; 3], + pub outer_angle: f32, + pub range: f32, + pub _padding: [f32; 7], +} + +pub struct Spotlight +{ + pub position: glam::Vec3, + pub direction: glam::Vec3, + pub inner_angle: f32, + pub outer_angle: f32, + pub range: f32, +} + +impl Spotlight +{ + pub fn new( + position: glam::Vec3, + direction: glam::Vec3, + inner_angle: f32, + outer_angle: f32, + range: f32, + ) -> Self + { + Self { + position, + direction: direction.normalize(), + inner_angle, + outer_angle, + range, + } + } + + pub fn to_raw(&self) -> SpotlightRaw + { + SpotlightRaw { + position: self.position.to_array(), + inner_angle: self.inner_angle, + direction: self.direction.to_array(), + outer_angle: self.outer_angle, + range: self.range, + _padding: [0.0; 7], + } + } +} #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] @@ -20,11 +73,13 @@ struct TerrainUniforms light_view_projection: [[f32; 4]; 4], camera_position: [f32; 3], height_scale: f32, + player_position: [f32; 3], time: f32, shadow_bias: f32, - _padding1: [f32; 2], - light_direction: [f32; 3], + spotlight_count: u32, + _padding1: u32, _padding2: u32, + spotlights: [SpotlightRaw; MAX_SPOTLIGHTS], } impl TerrainUniforms @@ -35,12 +90,19 @@ impl TerrainUniforms projection: Mat4, light_view_projection: Mat4, camera_position: glam::Vec3, + player_position: glam::Vec3, height_scale: f32, time: f32, shadow_bias: f32, - light_direction: glam::Vec3, + spotlights: &[Spotlight], ) -> Self { + let mut spotlight_array = [SpotlightRaw::default(); MAX_SPOTLIGHTS]; + for (i, spotlight) in spotlights.iter().take(MAX_SPOTLIGHTS).enumerate() + { + spotlight_array[i] = spotlight.to_raw(); + } + Self { model: model.to_cols_array_2d(), view: view.to_cols_array_2d(), @@ -48,11 +110,13 @@ impl TerrainUniforms light_view_projection: light_view_projection.to_cols_array_2d(), camera_position: camera_position.to_array(), height_scale, + player_position: player_position.to_array(), time, shadow_bias, - _padding1: [0.0; 2], - light_direction: light_direction.to_array(), + spotlight_count: spotlights.len().min(MAX_SPOTLIGHTS) as u32, + _padding1: 0, _padding2: 0, + spotlights: spotlight_array, } } } @@ -60,9 +124,11 @@ impl TerrainUniforms #[derive(Clone, Copy)] pub enum Pipeline { - Render, + Standard, Terrain, + Environment, Wireframe, + Snow, } pub struct DrawCall @@ -85,6 +151,7 @@ pub struct Renderer framebuffer: LowResFramebuffer, render_pipeline: wgpu::RenderPipeline, + environment_pipeline: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, @@ -106,11 +173,13 @@ pub struct Renderer shadow_bind_group: Option, wireframe_pipeline: wgpu::RenderPipeline, + snow_pipeline: wgpu::RenderPipeline, + snow_bind_group_layout: wgpu::BindGroupLayout, + snow_persistent_light_layout: wgpu::BindGroupLayout, + snow_bind_group: wgpu::BindGroup, + snow_persistent_light_bind_group: Option, - pub light_direction: glam::Vec3, - pub shadow_focus_point: glam::Vec3, - pub shadow_ortho_size: f32, - pub shadow_distance: f32, + pub spotlights: Vec, pub shadow_bias: f32, shadow_map_texture: wgpu::Texture, @@ -120,6 +189,10 @@ pub struct Renderer dither_textures: Option, flowmap_texture: Option, + blue_noise_view: wgpu::TextureView, + blue_noise_sampler: wgpu::Sampler, + + snow_light_accumulation: Option, } impl Renderer @@ -196,22 +269,74 @@ impl Renderer } }; - let flowmap_texture = match FlowmapTexture::load(&device, &queue, "textures/terrain_flowmap.exr") - { - Ok(texture) => + let flowmap_texture = + match FlowmapTexture::load(&device, &queue, "textures/terrain_flowmap.exr") { - println!("Loaded terrain flowmap successfully"); - Some(texture) - } - Err(e) => - { - eprintln!( - "Warning: Could not load terrain flowmap: {}. Path lighting will not work.", - e - ); - None - } - }; + Ok(texture) => + { + println!("Loaded terrain flowmap successfully"); + Some(texture) + } + Err(e) => + { + eprintln!( + "Warning: Could not load terrain flowmap: {}. Path lighting will not work.", + e + ); + None + } + }; + + let blue_noise_data = image::open("textures/blue_noise.png") + .expect("Failed to load blue noise texture") + .to_luma8(); + let blue_noise_size = blue_noise_data.dimensions(); + + let blue_noise_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Blue Noise"), + size: wgpu::Extent3d { + width: blue_noise_size.0, + height: blue_noise_size.1, + depth_or_array_layers: 1, + }, + 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: &blue_noise_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &blue_noise_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(blue_noise_size.0), + rows_per_image: Some(blue_noise_size.1), + }, + wgpu::Extent3d { + width: blue_noise_size.0, + height: blue_noise_size.1, + depth_or_array_layers: 1, + }, + ); + + let blue_noise_view = + blue_noise_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let blue_noise_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Blue Noise Sampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); let shadow_map_size = 4096; let shadow_map_texture = device.create_texture(&wgpu::TextureDescriptor { @@ -229,7 +354,8 @@ impl Renderer view_formats: &[], }); - let shadow_map_view = shadow_map_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let shadow_map_view = + shadow_map_texture.create_view(&wgpu::TextureViewDescriptor::default()); let shadow_map_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("Shadow Map Sampler"), @@ -292,7 +418,7 @@ impl Renderer binding: 5, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, + sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, @@ -301,13 +427,30 @@ impl Renderer wgpu::BindGroupLayoutEntry { binding: 6, visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), count: None, }, ], }); - let bind_group = if let (Some(ref dither_tex), Some(ref flowmap)) = (&dither_textures, &flowmap_texture) + let bind_group = if let (Some(ref dither_tex), Some(ref flowmap)) = + (&dither_textures, &flowmap_texture) { device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Bind Group"), @@ -341,6 +484,14 @@ impl Renderer binding: 6, resource: wgpu::BindingResource::Sampler(&flowmap.sampler), }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&blue_noise_view), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(&blue_noise_sampler), + }, ], }) } @@ -350,6 +501,8 @@ impl Renderer }; let render_pipeline = create_render_pipeline(&device, &config, &bind_group_layout); + let environment_pipeline = + create_environment_pipeline(&device, &config, &bind_group_layout); let (quad_vb, quad_ib, quad_num_indices) = create_fullscreen_quad(&device); @@ -396,23 +549,33 @@ impl Renderer let shadow_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Shadow 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: None, - }, - count: None, + 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: None, }, - ], + count: None, + }], }); let wireframe_pipeline = create_wireframe_pipeline(&device, config.format, &bind_group_layout); + let snow_persistent_light_layout = + crate::snow_light::SnowLightAccumulation::create_bind_group_layout(&device); + + let snow_pipeline = create_snow_pipeline( + &device, + config.format, + &bind_group_layout, + &snow_persistent_light_layout, + ); + + let snow_bind_group_layout = bind_group_layout.clone(); + Ok(Self { device, queue, @@ -420,6 +583,7 @@ impl Renderer config, framebuffer, render_pipeline, + environment_pipeline, uniform_buffer: uniform_buffer.clone(), bind_group: bind_group.clone(), quad_vb, @@ -428,62 +592,118 @@ impl Renderer blit_pipeline, blit_bind_group, terrain_pipeline: None, - terrain_bind_group_layout: bind_group_layout, + terrain_bind_group_layout: bind_group_layout.clone(), terrain_uniform_buffer: uniform_buffer, - terrain_bind_group: Some(bind_group), + terrain_bind_group: Some(bind_group.clone()), terrain_height_scale: 10.0, shadow_pipeline: None, shadow_bind_group_layout, shadow_bind_group: None, wireframe_pipeline, - light_direction: glam::Vec3::new(-1.0, -0.5, 1.0).normalize(), - shadow_focus_point: glam::Vec3::ZERO, - shadow_ortho_size: 600.0, - shadow_distance: 1000.0, - shadow_bias: 0.001, + snow_pipeline, + snow_bind_group_layout, + snow_persistent_light_layout, + snow_bind_group: bind_group, + snow_persistent_light_bind_group: None, + spotlights: vec![Spotlight::new( + glam::Vec3::new(0.0, 50.0, 0.0), + glam::Vec3::new(-0.5, -1.0, 1.0).normalize(), + 0.4, + 1.0, + 100.0, + )], + shadow_bias: 0.005, shadow_map_texture, shadow_map_view, shadow_map_sampler, shadow_map_size, dither_textures, flowmap_texture, + blue_noise_view, + blue_noise_sampler, + snow_light_accumulation: None, }) } - pub fn render(&mut self, camera: &Camera, draw_calls: &[DrawCall], time: f32) + pub fn render( + &mut self, + view: &glam::Mat4, + projection: &glam::Mat4, + camera_position: glam::Vec3, + player_position: glam::Vec3, + draw_calls: &[DrawCall], + time: f32, + delta_time: f32, + ) { - let view = camera.view_matrix(); - let projection = camera.projection_matrix(); let light_view_projection = self.calculate_light_view_projection(); - self.render_shadow_pass(draw_calls, light_view_projection, time); + if let Some(ref mut snow_light_accum) = self.snow_light_accumulation + { + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Snow Light Accumulation Encoder"), + }); + + snow_light_accum.render( + &mut encoder, + &self.queue, + &self.spotlights, + delta_time, + &light_view_projection, + self.shadow_bias, + self.terrain_height_scale, + ); + + self.queue.submit(std::iter::once(encoder.finish())); + + if self.snow_persistent_light_bind_group.is_none() + { + let bind_group = snow_light_accum + .create_read_bind_group(&self.device, &self.snow_persistent_light_layout); + self.snow_persistent_light_bind_group = Some(bind_group); + } + } + + self.render_shadow_pass(draw_calls, light_view_projection, player_position, time); for (i, draw_call) in draw_calls.iter().enumerate() { - let uniforms = TerrainUniforms::new( - draw_call.model, - view, - projection, - light_view_projection, - camera.position, - self.terrain_height_scale, - time, - self.shadow_bias, - self.light_direction, - ); - match draw_call.pipeline { - Pipeline::Render | Pipeline::Wireframe => + Pipeline::Standard | Pipeline::Wireframe => { + let light_dir = if self.spotlights.is_empty() + { + glam::Vec3::new(0.0, -1.0, 0.0) + } + else + { + self.spotlights[0].direction + }; + let uniforms = + CameraUniforms::new(draw_call.model, *view, *projection, light_dir); self.queue.write_buffer( &self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]), ); } - Pipeline::Terrain => + Pipeline::Snow | Pipeline::Terrain | Pipeline::Environment => { + let uniforms = TerrainUniforms::new( + draw_call.model, + *view, + *projection, + light_view_projection, + camera_position, + player_position, + self.terrain_height_scale, + time, + self.shadow_bias, + &self.spotlights, + ); self.queue.write_buffer( &self.terrain_uniform_buffer, 0, @@ -508,9 +728,9 @@ impl Renderer load: if i == 0 { wgpu::LoadOp::Clear(wgpu::Color { - r: 0.1, + r: 0.2, g: 0.2, - b: 0.3, + b: 0.2, a: 1.0, }) } @@ -543,25 +763,42 @@ impl Renderer let pipeline = match draw_call.pipeline { - Pipeline::Render => &self.render_pipeline, + Pipeline::Standard => &self.render_pipeline, Pipeline::Terrain => &self .terrain_pipeline .as_ref() .expect("terrain_data_missing"), + Pipeline::Environment => &self.environment_pipeline, Pipeline::Wireframe => &self.wireframe_pipeline, + Pipeline::Snow => &self.snow_pipeline, }; - let bind_group = match draw_call.pipeline - { - Pipeline::Render => &self.bind_group, - Pipeline::Terrain => &self - .terrain_bind_group - .as_ref() - .expect("terrain data missing"), - Pipeline::Wireframe => &self.bind_group, - }; - render_pass.set_pipeline(pipeline); - render_pass.set_bind_group(0, bind_group, &[]); + + match draw_call.pipeline + { + Pipeline::Standard | Pipeline::Wireframe => + { + render_pass.set_bind_group(0, &self.bind_group, &[]); + } + Pipeline::Terrain | Pipeline::Environment => + { + let bind_group = self + .terrain_bind_group + .as_ref() + .expect("terrain_data_missing"); + render_pass.set_bind_group(0, bind_group, &[]); + } + Pipeline::Snow => + { + render_pass.set_bind_group(0, &self.snow_bind_group, &[]); + if let Some(ref persistent_light_bind_group) = + self.snow_persistent_light_bind_group + { + render_pass.set_bind_group(1, persistent_light_bind_group, &[]); + } + } + } + render_pass.set_vertex_buffer(0, draw_call.vertex_buffer.slice(..)); if let Some(ref instance_buffer) = draw_call.instance_buffer @@ -627,191 +864,6 @@ impl Renderer frame.present(); } - pub fn render_with_matrices( - &mut self, - view: &glam::Mat4, - projection: &glam::Mat4, - camera_position: glam::Vec3, - draw_calls: &[DrawCall], - time: f32, - ) - { - let light_view_projection = self.calculate_light_view_projection(); - - self.render_shadow_pass(draw_calls, light_view_projection, time); - - for (i, draw_call) in draw_calls.iter().enumerate() - { - match draw_call.pipeline - { - Pipeline::Render | Pipeline::Wireframe => - { - let uniforms = CameraUniforms::new(draw_call.model, *view, *projection, self.light_direction); - self.queue.write_buffer( - &self.uniform_buffer, - 0, - bytemuck::cast_slice(&[uniforms]), - ); - } - Pipeline::Terrain => - { - let uniforms = TerrainUniforms::new( - draw_call.model, - *view, - *projection, - light_view_projection, - camera_position, - self.terrain_height_scale, - time, - self.shadow_bias, - self.light_direction, - ); - self.queue.write_buffer( - &self.terrain_uniform_buffer, - 0, - bytemuck::cast_slice(&[uniforms]), - ); - } - } - - let mut encoder = self - .device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Render Encoder"), - }); - - { - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("3D Render Pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.framebuffer.view, - resolve_target: None, - ops: wgpu::Operations { - load: if i == 0 - { - wgpu::LoadOp::Clear(wgpu::Color { - r: 0.1, - g: 0.2, - b: 0.3, - a: 1.0, - }) - } - else - { - wgpu::LoadOp::Load - }, - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.framebuffer.depth_view, - depth_ops: Some(wgpu::Operations { - load: if i == 0 - { - wgpu::LoadOp::Clear(1.0) - } - else - { - wgpu::LoadOp::Load - }, - store: wgpu::StoreOp::Store, - }), - stencil_ops: None, - }), - timestamp_writes: None, - occlusion_query_set: None, - }); - - let pipeline = match draw_call.pipeline - { - Pipeline::Render => &self.render_pipeline, - Pipeline::Terrain => &self - .terrain_pipeline - .as_ref() - .expect("terrain_data_missing"), - Pipeline::Wireframe => &self.wireframe_pipeline, - }; - let bind_group = match draw_call.pipeline - { - Pipeline::Render => &self.bind_group, - Pipeline::Terrain => &self - .terrain_bind_group - .as_ref() - .expect("terrain_data_missing"), - Pipeline::Wireframe => &self.bind_group, - }; - - render_pass.set_pipeline(pipeline); - render_pass.set_bind_group(0, bind_group, &[]); - - render_pass.set_vertex_buffer(0, draw_call.vertex_buffer.slice(..)); - - if let Some(ref instance_buffer) = draw_call.instance_buffer - { - render_pass.set_vertex_buffer(1, instance_buffer.slice(..)); - } - - render_pass.set_index_buffer( - draw_call.index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); - render_pass.draw_indexed(0..draw_call.num_indices, 0, 0..draw_call.num_instances); - } - - self.queue.submit(std::iter::once(encoder.finish())); - } - - let frame = match self.surface.get_current_texture() - { - Ok(frame) => frame, - Err(_) => - { - self.surface.configure(&self.device, &self.config); - self.surface - .get_current_texture() - .expect("Failed to acquire next surface texture") - } - }; - - let screen_view = frame - .texture - .create_view(&wgpu::TextureViewDescriptor::default()); - - let mut blit_encoder = - self.device - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("Blit Encoder"), - }); - - { - let mut blit_pass = blit_encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("Blit Pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &screen_view, - resolve_target: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), - store: wgpu::StoreOp::Store, - }, - depth_slice: None, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - }); - - blit_pass.set_pipeline(&self.blit_pipeline); - blit_pass.set_bind_group(0, &self.blit_bind_group, &[]); - blit_pass.set_vertex_buffer(0, self.quad_vb.slice(..)); - blit_pass.set_index_buffer(self.quad_ib.slice(..), wgpu::IndexFormat::Uint16); - blit_pass.draw_indexed(0..self.quad_num_indices, 0, 0..1); - } - - self.queue.submit(std::iter::once(blit_encoder.finish())); - frame.present(); - } - pub fn render_scale(&self) -> (u32, u32) { ( @@ -864,18 +916,24 @@ impl Renderer binding: 6, resource: wgpu::BindingResource::Sampler(&flowmap_texture.sampler), }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.blue_noise_view), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(&self.blue_noise_sampler), + }, ], }); let shadow_bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("Shadow Bind Group"), layout: &self.shadow_bind_group_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.terrain_uniform_buffer.as_entire_binding(), - }, - ], + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: self.terrain_uniform_buffer.as_entire_binding(), + }], }); let terrain_pipeline = create_terrain_render_pipeline( @@ -893,6 +951,63 @@ impl Renderer self.terrain_height_scale = 1.0; } + 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, + ); + + self.snow_light_accumulation = Some(snow_light_accumulation); + } + + pub fn set_snow_depth(&mut self, snow_depth_view: &wgpu::TextureView) + { + println!("set_snow_depth() called"); + if let Some(ref mut snow_light_accum) = self.snow_light_accumulation + { + println!("Snow light accumulation exists, setting up bind groups"); + let snow_depth_sampler = self.device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Snow Depth Sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + match crate::texture::HeightmapTexture::load( + &self.device, + &self.queue, + "textures/terrain_heightmap.exr", + ) + { + Ok(heightmap) => + { + snow_light_accum.set_heightmap( + &self.device, + &heightmap.view, + &heightmap.sampler, + &self.shadow_map_view, + &self.shadow_map_sampler, + snow_depth_view, + &snow_depth_sampler, + ); + + let bind_group = snow_light_accum + .create_read_bind_group(&self.device, &self.snow_persistent_light_layout); + self.snow_persistent_light_bind_group = Some(bind_group); + } + Err(e) => + { + eprintln!("Failed to load heightmap for snow depth setup: {}", e); + } + } + } + } + pub fn get_device(&self) -> &wgpu::Device { &self.device @@ -907,9 +1022,28 @@ impl Renderer &mut self, draw_calls: &[DrawCall], light_view_projection: Mat4, + player_position: glam::Vec3, time: f32, ) { + let uniforms = TerrainUniforms::new( + Mat4::IDENTITY, + Mat4::IDENTITY, + light_view_projection, + light_view_projection, + glam::Vec3::ZERO, + player_position, + self.terrain_height_scale, + time, + self.shadow_bias, + &self.spotlights, + ); + self.queue.write_buffer( + &self.terrain_uniform_buffer, + 0, + bytemuck::cast_slice(&[uniforms]), + ); + let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { @@ -947,28 +1081,14 @@ impl Renderer for draw_call in draw_calls.iter() { - if !matches!(draw_call.pipeline, Pipeline::Terrain) + if !matches!( + draw_call.pipeline, + Pipeline::Terrain | Pipeline::Standard | Pipeline::Environment | Pipeline::Snow + ) { continue; } - let uniforms = TerrainUniforms::new( - draw_call.model, - Mat4::IDENTITY, - light_view_projection, - light_view_projection, - glam::Vec3::ZERO, - self.terrain_height_scale, - time, - self.shadow_bias, - self.light_direction, - ); - self.queue.write_buffer( - &self.terrain_uniform_buffer, - 0, - bytemuck::cast_slice(&[uniforms]), - ); - shadow_pass.set_vertex_buffer(0, draw_call.vertex_buffer.slice(..)); if let Some(ref instance_buffer) = draw_call.instance_buffer @@ -976,10 +1096,8 @@ impl Renderer shadow_pass.set_vertex_buffer(1, instance_buffer.slice(..)); } - shadow_pass.set_index_buffer( - draw_call.index_buffer.slice(..), - wgpu::IndexFormat::Uint32, - ); + shadow_pass + .set_index_buffer(draw_call.index_buffer.slice(..), wgpu::IndexFormat::Uint32); shadow_pass.draw_indexed(0..draw_call.num_indices, 0, 0..draw_call.num_instances); } } @@ -989,29 +1107,67 @@ impl Renderer fn calculate_light_view_projection(&self) -> Mat4 { - let light_dir = self.light_direction.normalize(); - let light_position = self.shadow_focus_point - light_dir * self.shadow_distance; + if self.spotlights.is_empty() + { + return Mat4::IDENTITY; + } - let light_view = Mat4::look_at_rh( - light_position, - self.shadow_focus_point, - glam::Vec3::Y, - ); + let spotlight = &self.spotlights[0]; + let light_position = spotlight.position; + let light_target = spotlight.position + spotlight.direction; - let far_plane = self.shadow_distance * 2.0 + 50.0; + let up_vector = if spotlight.direction.y.abs() > 0.99 + { + glam::Vec3::Z + } + else + { + glam::Vec3::Y + }; + + let light_view = Mat4::look_at_rh(light_position, light_target, up_vector); + + let fov = spotlight.outer_angle * 2.0; + let near = 0.1; + let far = 150.0; + let light_projection = Mat4::perspective_rh(fov, 1.0, near, far); + + light_projection * light_view + } + + fn calculate_light_view_projection_ortho(&self) -> Mat4 + { + let light_dir = self.spotlights[0].direction; + let shadow_focus_point = glam::Vec3::ZERO; + let shadow_ortho_size = 600.0; + let shadow_distance = 1000.0; + let light_position = shadow_focus_point - light_dir * shadow_distance; + + let up_vector = if light_dir.y.abs() > 0.99 + { + glam::Vec3::Z + } + else + { + glam::Vec3::Y + }; + + let light_view = Mat4::look_at_rh(light_position, shadow_focus_point, up_vector); + + let far_plane = shadow_distance * 2.0 + 50.0; let light_projection = Mat4::orthographic_rh( - -self.shadow_ortho_size, - self.shadow_ortho_size, - -self.shadow_ortho_size, - self.shadow_ortho_size, + -shadow_ortho_size, + shadow_ortho_size, + -shadow_ortho_size, + shadow_ortho_size, 0.1, far_plane, ); println!("Shadow Frustum - Size: {:.1}×{:.1}, Coverage: {:.1}×{:.1}, Depth: 0.1-{:.1}, Focus: {:?}, Light: {:?}", - self.shadow_ortho_size * 2.0, self.shadow_ortho_size * 2.0, - self.shadow_ortho_size, self.shadow_ortho_size, - far_plane, self.shadow_focus_point, light_position); + shadow_ortho_size * 2.0, shadow_ortho_size * 2.0, + shadow_ortho_size, shadow_ortho_size, + far_plane, shadow_focus_point, light_position); light_projection * light_view } @@ -1057,6 +1213,24 @@ pub fn 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| { @@ -1066,54 +1240,28 @@ pub fn aspect_ratio() -> f32 }) } -pub fn render(camera: &Camera, draw_calls: &[DrawCall], time: f32) -{ - GLOBAL_RENDERER.with(|r| { - let mut renderer = r.borrow_mut(); - let renderer = renderer.as_mut().expect("Renderer not set"); - renderer.render(camera, draw_calls, time); - }); -} - -pub fn render_with_matrices( +pub fn render( view: &glam::Mat4, projection: &glam::Mat4, camera_position: glam::Vec3, + player_position: glam::Vec3, draw_calls: &[DrawCall], time: f32, + delta_time: f32, ) { GLOBAL_RENDERER.with(|r| { let mut renderer = r.borrow_mut(); let renderer = renderer.as_mut().expect("Renderer not set"); - renderer.render_with_matrices(view, projection, camera_position, draw_calls, time); - }); -} - -pub fn set_shadow_focus_point(focus_point: glam::Vec3) -{ - GLOBAL_RENDERER.with(|r| { - let mut renderer = r.borrow_mut(); - let renderer = renderer.as_mut().expect("Renderer not set"); - renderer.shadow_focus_point = focus_point; - }); -} - -pub fn set_shadow_ortho_size(size: f32) -{ - GLOBAL_RENDERER.with(|r| { - let mut renderer = r.borrow_mut(); - let renderer = renderer.as_mut().expect("Renderer not set"); - renderer.shadow_ortho_size = size; - }); -} - -pub fn set_shadow_distance(distance: f32) -{ - GLOBAL_RENDERER.with(|r| { - let mut renderer = r.borrow_mut(); - let renderer = renderer.as_mut().expect("Renderer not set"); - renderer.shadow_distance = distance; + renderer.render( + view, + projection, + camera_position, + player_position, + draw_calls, + time, + delta_time, + ); }); } @@ -1126,16 +1274,26 @@ pub fn set_shadow_bias(bias: f32) }); } +pub fn update_spotlights(spotlights: Vec) +{ + GLOBAL_RENDERER.with(|r| { + let mut renderer = r.borrow_mut(); + let renderer = renderer.as_mut().expect("Renderer not set"); + renderer.spotlights = spotlights; + }); +} + fn create_shadow_pipeline( device: &wgpu::Device, bind_group_layout: &wgpu::BindGroupLayout, ) -> wgpu::RenderPipeline { - let shared_source = - std::fs::read_to_string("shaders/shared.wgsl").expect("Failed to read shared shader"); - let terrain_source = - std::fs::read_to_string("shaders/terrain.wgsl").expect("Failed to read terrain shader"); - let shader_source = format!("{}\n{}", shared_source, terrain_source); + let compiler = Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::shadow".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Shadow Shader"), @@ -1154,7 +1312,10 @@ fn create_shadow_pipeline( vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), - buffers: &[crate::mesh::Vertex::desc(), crate::mesh::InstanceRaw::desc()], + buffers: &[ + crate::mesh::Vertex::desc(), + crate::mesh::InstanceRaw::desc(), + ], compilation_options: Default::default(), }, fragment: None, @@ -1190,11 +1351,12 @@ fn create_wireframe_pipeline( bind_group_layout: &wgpu::BindGroupLayout, ) -> wgpu::RenderPipeline { - let shared_source = - std::fs::read_to_string("shaders/shared.wgsl").expect("Failed to read shared shader"); - let standard_source = - std::fs::read_to_string("shaders/standard.wgsl").expect("Failed to read shader"); - let shader_source = format!("{}\n{}", shared_source, standard_source); + let compiler = Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::standard".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Wireframe Shader"), @@ -1213,7 +1375,10 @@ fn create_wireframe_pipeline( vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), - buffers: &[crate::mesh::Vertex::desc(), crate::mesh::InstanceRaw::desc()], + buffers: &[ + crate::mesh::Vertex::desc(), + crate::mesh::InstanceRaw::desc(), + ], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { @@ -1251,3 +1416,80 @@ fn create_wireframe_pipeline( cache: None, }) } + +fn create_snow_pipeline( + device: &wgpu::Device, + format: wgpu::TextureFormat, + bind_group_layout_0: &wgpu::BindGroupLayout, + bind_group_layout_1: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline +{ + let compiler = Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::snow".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Snow Shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Snow Pipeline Layout"), + bind_group_layouts: &[bind_group_layout_0, bind_group_layout_1], + push_constant_ranges: &[], + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Snow Pipeline"), + layout: Some(&render_pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[ + crate::mesh::Vertex::desc(), + crate::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::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState { + constant: -1, + slope_scale: -1.0, + clamp: 0.0, + }, + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }) +} diff --git a/src/shader.rs b/src/shader.rs index 075b0b3..8fa535a 100644 --- a/src/shader.rs +++ b/src/shader.rs @@ -1,4 +1,5 @@ use crate::mesh::{InstanceRaw, Vertex}; +use wesl::{include_wesl, Wesl}; pub fn create_render_pipeline( device: &wgpu::Device, @@ -6,11 +7,12 @@ pub fn create_render_pipeline( bind_group_layout: &wgpu::BindGroupLayout, ) -> wgpu::RenderPipeline { - let shared_source = - std::fs::read_to_string("shaders/shared.wgsl").expect("Failed to read shared shader"); - let standard_source = - std::fs::read_to_string("shaders/standard.wgsl").expect("Failed to read standard shader"); - let shader_source = format!("{}\n{}", shared_source, standard_source); + let compiler = Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::standard".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Shader"), @@ -67,3 +69,72 @@ pub fn create_render_pipeline( cache: None, }) } + +pub fn create_environment_pipeline( + device: &wgpu::Device, + config: &wgpu::SurfaceConfiguration, + bind_group_layout: &wgpu::BindGroupLayout, +) -> wgpu::RenderPipeline +{ + let compiler = Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::environment".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Environment Shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Environment Pipeline Layout"), + bind_group_layouts: &[bind_group_layout], + push_constant_ranges: &[], + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Environment Pipeline"), + layout: Some(&render_pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[Vertex::desc(), InstanceRaw::desc()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: config.format, + blend: Some(wgpu::BlendState::REPLACE), + 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: Some(wgpu::Face::Back), + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }) +} diff --git a/shaders/blit.wgsl b/src/shaders/blit.wgsl similarity index 100% rename from shaders/blit.wgsl rename to src/shaders/blit.wgsl diff --git a/src/shaders/environment.wesl b/src/shaders/environment.wesl new file mode 100644 index 0000000..4a24f12 --- /dev/null +++ b/src/shaders/environment.wesl @@ -0,0 +1,95 @@ +import package::shared::{VertexInput, VertexOutput, sample_shadow_map, flowmap_path_lighting_with_shadow, all_spotlights_lighting, uniforms, blue_noise_texture, blue_noise_sampler}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + let instance_model = mat4x4( + input.instance_model_0, + input.instance_model_1, + input.instance_model_2, + input.instance_model_3 + ); + + let world_pos = instance_model * vec4(input.position, 1.0); + output.world_position = world_pos.xyz; + output.clip_position = uniforms.projection * uniforms.view * world_pos; + + let normal_matrix = mat3x3( + instance_model[0].xyz, + instance_model[1].xyz, + instance_model[2].xyz + ); + output.world_normal = normalize(normal_matrix * input.normal); + + output.light_space_position = uniforms.light_view_projection * world_pos; + + let instance_position = vec3( + instance_model[3][0], + instance_model[3][1], + instance_model[3][2] + ); + + let to_player = uniforms.player_position - uniforms.camera_position; + let distance_to_player = length(to_player); + + var dissolve_amount = 0.0; + + if distance_to_player > 0.01 { + let ray_dir = to_player / distance_to_player; + let ray_origin = uniforms.camera_position; + + let tree_height = 16.0; + let occlusion_radius = 6.5; + + let w = instance_position - ray_origin; + let projection_t = dot(w, ray_dir); + + if projection_t > 0.0 && projection_t < distance_to_player { + let closest_on_ray = ray_origin + ray_dir * projection_t; + + let diff = closest_on_ray - instance_position; + let height_on_trunk = clamp(diff.y, 0.0, tree_height); + let closest_on_trunk = instance_position + vec3(0.0, height_on_trunk, 0.0); + + let perp_distance = length(closest_on_trunk - closest_on_ray); + + if perp_distance < occlusion_radius { + let dissolve_t = pow(perp_distance / occlusion_radius, 0.5); + dissolve_amount = 1.0 - clamp(dissolve_t, 0.0, 1.0); + } + } + } + + output.dissolve_amount = dissolve_amount; + + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + + let debug = 0u; + + if debug == 1u { + return vec4(input.dissolve_amount); + } + + if input.dissolve_amount > 0.0 { + let screen_pos = input.clip_position.xy; + let noise_uv = fract(screen_pos / 128.0); + let noise_value = textureSampleLevel(blue_noise_texture, blue_noise_sampler, noise_uv, 0.0).r; + + if noise_value < input.dissolve_amount { + discard; + } + } + + let shadow = sample_shadow_map(input.light_space_position); + + let tile_scale = 4.0; + let spotlight_strokes = all_spotlights_lighting(input.world_position, input.clip_position, input.world_normal, tile_scale, shadow); + let brightness = spotlight_strokes; + + return vec4(brightness, brightness, brightness, 1.0); +} diff --git a/src/shaders/shadow.wesl b/src/shaders/shadow.wesl new file mode 100644 index 0000000..c23dbec --- /dev/null +++ b/src/shaders/shadow.wesl @@ -0,0 +1,14 @@ +import package::shared::{ VertexInput, uniforms }; + +@vertex +fn vs_main(input: VertexInput) -> @builtin(position) vec4 { + let instance_model = mat4x4( + input.instance_model_0, + input.instance_model_1, + input.instance_model_2, + input.instance_model_3 + ); + + let world_pos = instance_model * vec4(input.position, 1.0); + return uniforms.light_view_projection * world_pos; +} diff --git a/shaders/shared.wgsl b/src/shaders/shared.wesl similarity index 60% rename from shaders/shared.wgsl rename to src/shaders/shared.wesl index 6b20ff7..1c38b5f 100644 --- a/shaders/shared.wgsl +++ b/src/shaders/shared.wesl @@ -6,6 +6,7 @@ struct VertexInput { @location(4) instance_model_1: vec4, @location(5) instance_model_2: vec4, @location(6) instance_model_3: vec4, + @location(7) instance_dissolve: f32, } struct VertexOutput { @@ -13,6 +14,24 @@ struct VertexOutput { @location(0) world_position: vec3, @location(1) world_normal: vec3, @location(2) light_space_position: vec4, + @location(3) dissolve_amount: f32, +} + +const MAX_SPOTLIGHTS: u32 = 4u; + +struct Spotlight { + position: vec3, + inner_angle: f32, + direction: vec3, + outer_angle: f32, + range: f32, + _padding: f32, + _padding2: f32, + _padding3: f32, + _padding4: f32, + _padding5: f32, + _padding6: f32, + _padding7: f32, } struct Uniforms { @@ -22,9 +41,13 @@ struct Uniforms { light_view_projection: mat4x4, camera_position: vec3, height_scale: f32, + player_position: vec3, time: f32, shadow_bias: f32, - light_direction: vec3, + spotlight_count: u32, + _padding1: u32, + _padding2: u32, + spotlights: array, } @group(0) @binding(0) @@ -48,6 +71,12 @@ var flowmap_texture: texture_2d; @group(0) @binding(6) var flowmap_sampler: sampler; +@group(0) @binding(7) +var blue_noise_texture: texture_2d; + +@group(0) @binding(8) +var blue_noise_sampler: sampler; + const PI: f32 = 3.14159265359; const TERRAIN_BOUNDS: vec2 = vec2(1000.0, 1000.0); const LINE_THICKNESS: f32 = 0.1; @@ -91,9 +120,7 @@ fn sample_shadow_map(light_space_pos: vec4) -> f32 { let proj_coords = light_space_pos.xyz / light_space_pos.w; let ndc_coords = proj_coords * vec3(0.5, -0.5, 1.0) + vec3(0.5, 0.5, 0.0); - if ndc_coords.x < 0.0 || ndc_coords.x > 1.0 || - ndc_coords.y < 0.0 || ndc_coords.y > 1.0 || - ndc_coords.z < 0.0 || ndc_coords.z > 1.0 { + if ndc_coords.x < 0.0 || ndc_coords.x > 1.0 || ndc_coords.y < 0.0 || ndc_coords.y > 1.0 || ndc_coords.z < 0.0 || ndc_coords.z > 1.0 { return 1.0; } @@ -103,7 +130,20 @@ fn sample_shadow_map(light_space_pos: vec4) -> f32 { return shadow; } -fn hatching_lighting(world_pos: vec3, tile_scale: f32, direction: vec2, distance: f32) -> f32 { +fn hatching_lighting(world_pos: vec3, clip_pos: vec4, tile_scale: f32, direction: vec2, distance: f32) -> f32 { + let octave_index = round((1.0 - pow(distance, 2.0)) * OCTAVE_STEPS); + let octave_normalized = octave_index / OCTAVE_STEPS; + + if octave_index > 3.0 { + return 1.0; + } else if octave_index < 1.0 { + let screen_pos = clip_pos.xy / clip_pos.w; + let blue_noise_uv = screen_pos * 0.5 + 0.5; + let blue_noise = textureSample(blue_noise_texture, blue_noise_sampler, blue_noise_uv * 10.0).r; + return step(blue_noise, 0.05); + } + + let world_pos_2d = vec2(world_pos.x, world_pos.z); let tile_size = 1.0 / tile_scale; let base_tile_center = floor(world_pos_2d / tile_size) * tile_size + tile_size * 0.5; @@ -119,8 +159,6 @@ fn hatching_lighting(world_pos: vec3, tile_scale: f32, direction: vec2 let parallel = mix(perpendicular_to_light, direction, t / 2.0); let perpendicular = compute_perpendicular(parallel); - let octave_index = round((1.0 - pow(distance, 2.0)) * OCTAVE_STEPS); - let spacing = LINE_THICKNESS * 1.5; var max_offset: i32; @@ -135,7 +173,7 @@ fn hatching_lighting(world_pos: vec3, tile_scale: f32, direction: vec2 max_offset = 9; } case 1 { - max_offset = 9; + max_offset = 6; } } for (var i: i32 = -max_offset; i <= max_offset; i++) { @@ -173,7 +211,7 @@ fn hatching_lighting(world_pos: vec3, tile_scale: f32, direction: vec2 local_pos, ); - let lighting = line_stroke_lighting(stroke_data); + let lighting = line_stroke_lighting(stroke_data, clip_pos); min_lighting = min(min_lighting, lighting); } } @@ -182,48 +220,94 @@ fn hatching_lighting(world_pos: vec3, tile_scale: f32, direction: vec2 return min_lighting; } -fn point_lighting(world_pos: vec3, point_light: vec3, tile_scale: f32) -> f32 { - let world_pos_2d = vec2(world_pos.x, world_pos.z); - let light_pos_2d = vec2(point_light.x, point_light.z); - let tile_size = 1.0 / tile_scale; - let tile_center = floor(world_pos_2d / tile_size) * tile_size + tile_size * 0.5; - - let direction_to_point = light_pos_2d - tile_center; - let distance_to_point = min(length(direction_to_point) / 60.0, 1.0); - let direction_normalized = normalize(direction_to_point); - - return hatching_lighting(world_pos, tile_scale, direction_normalized, distance_to_point); +struct SpotlightData { + direction_normalized: vec2, + combined_distance: f32, + is_lit: bool, } -fn point_lighting_with_shadow(world_pos: vec3, normal: vec3, point_light: vec3, tile_scale: f32, shadow: f32) -> f32 { - let world_pos_2d = vec2(world_pos.x, world_pos.z); - let light_pos_2d = vec2(point_light.x, point_light.z); - let tile_size = 1.0 / tile_scale; - let tile_center = floor(world_pos_2d / tile_size) * tile_size + tile_size * 0.5; +fn calculate_spotlight_data(world_pos: vec3, normal: vec3, spotlight: Spotlight, tile_scale: f32, shadow: f32) -> SpotlightData { + var data: SpotlightData; + data.is_lit = false; + data.direction_normalized = vec2(0.0, 0.0); + data.combined_distance = 2.0; - let direction_to_point_3d = normalize(point_light - world_pos); - let diffuse = max(0.0, dot(normalize(normal), direction_to_point_3d)); + let to_fragment = normalize(world_pos - spotlight.position); + let angle_to_fragment = acos(dot(to_fragment, spotlight.direction)); - let direction_to_point = light_pos_2d - tile_center; - let distance_to_point = min(length(direction_to_point) / 60.0, 1.0); - let direction_normalized = normalize(direction_to_point); + if angle_to_fragment > spotlight.outer_angle { + return data; + } - let lighting_intensity = shadow * diffuse; + let cube_size = 1.0 / tile_scale; + let cube_center = vec3( + floor(world_pos.x / cube_size) * cube_size + cube_size * 0.5, + floor(world_pos.y / cube_size) * cube_size + cube_size * 0.5, + floor(world_pos.z / cube_size) * cube_size + cube_size * 0.5 + ); + let tile_center = vec2(cube_center.x, cube_center.z); + + let angular_falloff = smoothstep(spotlight.inner_angle, spotlight.outer_angle, angle_to_fragment); + + let direction_to_point_3d = normalize(spotlight.position - cube_center); + let diffuse_raw = max(0.0, dot(normalize(normal), direction_to_point_3d)); + let diffuse_res = 4.0; + let diffuse = floor(diffuse_raw * diffuse_res) / diffuse_res; + + let t = (cube_center.y - spotlight.position.y) / spotlight.direction.y; + let hit_point = spotlight.position + spotlight.direction * t; + let hit_point_2d = vec2(hit_point.x, hit_point.z); + let direction_to_hit = hit_point_2d - tile_center; + let distance_to_hit = min(length(direction_to_hit) / spotlight.range, 1.0); + data.direction_normalized = normalize(direction_to_hit); + + let lighting_intensity = shadow * pow(diffuse, 0.5) * (1.0 - angular_falloff); let darkness = 1.0 - lighting_intensity; - let combined_distance = min(distance_to_point + darkness * 0.5, 1.0); + data.combined_distance = distance_to_hit + darkness; - return hatching_lighting(world_pos, tile_scale, direction_normalized, combined_distance); + data.is_lit = data.combined_distance <= 1.0; + + return data; } -fn line_stroke_lighting(data: StrokeData) -> f32 { - let octave_normalized = data.octave_index / OCTAVE_STEPS; +fn is_in_spotlight_light_area(world_pos: vec3, normal: vec3, spotlight: Spotlight, tile_scale: f32, shadow: f32) -> bool { + return calculate_spotlight_data(world_pos, normal, spotlight, tile_scale, shadow).is_lit; +} - if data.octave_index > 3.0 { - return 1.0; - } else if data.octave_index < 1.0 { +fn is_in_any_spotlight_light_area(world_pos: vec3, normal: vec3, tile_scale: f32, shadow: f32) -> bool { + for (var i = 0u; i < uniforms.spotlight_count; i++) { + if is_in_spotlight_light_area(world_pos, normal, uniforms.spotlights[i], tile_scale, shadow) { + return true; + } + } + return false; +} + +fn spotlight_lighting(world_pos: vec3, clip_pos: vec4, normal: vec3, spotlight: Spotlight, tile_scale: f32, shadow: f32) -> f32 { + let data = calculate_spotlight_data(world_pos, normal, spotlight, tile_scale, shadow); + + if !data.is_lit { return 0.0; } + return hatching_lighting(world_pos, clip_pos, tile_scale, data.direction_normalized, data.combined_distance); +} + +fn all_spotlights_lighting(world_pos: vec3, clip_pos: vec4, normal: vec3, tile_scale: f32, shadow: f32) -> f32 { + var max_lighting = 0.0; + + for (var i = 0u; i < uniforms.spotlight_count; i++) { + let spotlight = uniforms.spotlights[i]; + let lighting = spotlight_lighting(world_pos, clip_pos, normal, spotlight, tile_scale, shadow); + max_lighting = max(max_lighting, lighting); + } + + return max_lighting; +} + +fn line_stroke_lighting(data: StrokeData, clip_pos: vec4) -> f32 { + let octave_normalized = data.octave_index / OCTAVE_STEPS; + let noise = hash2(data.tile_center + data.offset) * 2.0 - 1.0; var noise_at_octave = noise; @@ -249,7 +333,7 @@ fn line_stroke_lighting(data: StrokeData) -> f32 { let parallel_coord = dot(data.local_pos, line); let perpendicular_coord = dot(data.local_pos, perpendicular_to_line); - let line_half_width = LINE_THICKNESS * (1.0 - octave_normalized * 0.5); + let line_half_width = LINE_THICKNESS * (1.0 - octave_normalized * 0.5) * data.tile_size; let straight_section_half_length = max(0.0, data.tile_size * 0.4 - line_half_width); let parallel_jitter = (rand(data.tile_center + data.offset * 123.456) * 2.0 - 1.0) * data.tile_size * jitter; @@ -261,7 +345,7 @@ fn line_stroke_lighting(data: StrokeData) -> f32 { return step(line_half_width, effective_distance); } -fn flowmap_path_lighting(world_pos: vec3, tile_scale: f32) -> f32 { +fn flowmap_path_lighting(world_pos: vec3, clip_pos: vec4, tile_scale: f32) -> f32 { let world_pos_2d = vec2(world_pos.x, world_pos.z); let tile_size = 1.0 / tile_scale; let tile_center = floor(world_pos_2d / tile_size) * tile_size + tile_size * 0.5; @@ -273,10 +357,10 @@ fn flowmap_path_lighting(world_pos: vec3, tile_scale: f32) -> f32 { let direction_to_path = normalize(vec2(x, y)); let distance_to_path = flowmap_sample.b; - return hatching_lighting(world_pos, tile_scale, direction_to_path, distance_to_path); + return hatching_lighting(world_pos, clip_pos, tile_scale, direction_to_path, distance_to_path); } -fn flowmap_path_lighting_with_shadow(world_pos: vec3, normal: vec3, tile_scale: f32, shadow: f32) -> f32 { +fn flowmap_path_lighting_with_shadow(world_pos: vec3, clip_pos: vec4, normal: vec3, tile_scale: f32, shadow: f32) -> f32 { let world_pos_2d = vec2(world_pos.x, world_pos.z); let tile_size = 1.0 / tile_scale; let tile_center = floor(world_pos_2d / tile_size) * tile_size + tile_size * 0.5; @@ -295,5 +379,5 @@ fn flowmap_path_lighting_with_shadow(world_pos: vec3, normal: vec3, ti let darkness = 1.0 - lighting_intensity; let combined_distance = min(distance_to_path + darkness * 0.5, 1.0); - return hatching_lighting(world_pos, tile_scale, direction_to_path, combined_distance); + return hatching_lighting(world_pos, clip_pos, tile_scale, direction_to_path, combined_distance); } diff --git a/src/shaders/snow.wesl b/src/shaders/snow.wesl new file mode 100644 index 0000000..8c51da3 --- /dev/null +++ b/src/shaders/snow.wesl @@ -0,0 +1,72 @@ +import package::shared::{ + VertexInput, + VertexOutput, + uniforms, + sample_shadow_map, + all_spotlights_lighting, + is_in_any_spotlight_light_area, + blue_noise_texture, + blue_noise_sampler, + TERRAIN_BOUNDS, + Spotlight +}; + +@group(1) @binding(0) +var persistent_light_texture: texture_2d; + +@group(1) @binding(1) +var persistent_light_sampler: sampler; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + let instance_model = mat4x4( + input.instance_model_0, + input.instance_model_1, + input.instance_model_2, + input.instance_model_3 + ); + + let world_pos = instance_model * vec4(input.position, 1.0); + output.world_position = world_pos.xyz; + output.clip_position = uniforms.projection * uniforms.view * world_pos; + + let normal_matrix = mat3x3( + instance_model[0].xyz, + instance_model[1].xyz, + instance_model[2].xyz + ); + output.world_normal = normalize(normal_matrix * input.normal); + + output.light_space_position = uniforms.light_view_projection * world_pos; + + return output; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let shadow = sample_shadow_map(in.light_space_position); + + let tile_scale = 2.0; + let in_spotlight_light_area = is_in_any_spotlight_light_area(in.world_position, in.world_normal, tile_scale, shadow); + let spotlight_strokes = all_spotlights_lighting(in.world_position, in.clip_position, in.world_normal, tile_scale, shadow); + + var brightness = spotlight_strokes; + + if !in_spotlight_light_area { + let terrain_uv = (vec2(in.world_position.x, in.world_position.z) + TERRAIN_BOUNDS * 0.5) / TERRAIN_BOUNDS; + let persistent_light = textureSample(persistent_light_texture, persistent_light_sampler, terrain_uv).r; + + if persistent_light > 0.05 { + let screen_pos = in.clip_position.xy / in.clip_position.w; + let blue_noise_uv = screen_pos * 0.5 + 0.5; + let blue_noise = textureSample(blue_noise_texture, blue_noise_sampler, blue_noise_uv * 10.0).r; + let blue_step = step(blue_noise, persistent_light / 30.0); + + brightness = max(brightness, blue_step); + } + } + + return vec4(brightness, brightness, brightness, 1.0); +} diff --git a/src/shaders/snow_deform.wgsl b/src/shaders/snow_deform.wgsl new file mode 100644 index 0000000..13736a2 --- /dev/null +++ b/src/shaders/snow_deform.wgsl @@ -0,0 +1,44 @@ +@group(0) @binding(0) +var snow_depth: texture_storage_2d; + +@group(0) @binding(1) +var params: DeformParams; + +struct DeformParams { + position_x: f32, + position_z: f32, + radius: f32, + depth: f32, +} + +@compute @workgroup_size(16, 16, 1) +fn deform(@builtin(global_invocation_id) global_id: vec3) { + let texture_size = textureDimensions(snow_depth); + + if (global_id.x >= texture_size.x || global_id.y >= texture_size.y) { + return; + } + + let coords = vec2(i32(global_id.x), i32(global_id.y)); + + let terrain_size = vec2(1000.0, 1000.0); + let half_size = terrain_size / 2.0; + + let uv = vec2(f32(global_id.x) / f32(texture_size.x), f32(global_id.y) / f32(texture_size.y)); + let world_pos = uv * terrain_size - half_size; + + let deform_center = vec2(params.position_x, params.position_z); + let distance = length(world_pos - deform_center); + + if (distance < params.radius) { + let current_depth = textureLoad(snow_depth, coords).r; + + let falloff = 1.0 - (distance / params.radius); + let falloff_smooth = falloff * falloff; + + let deform_amount = params.depth * falloff_smooth; + let new_depth = max(0.0, current_depth - deform_amount); + + textureStore(snow_depth, coords, vec4(new_depth, 0.0, 0.0, 0.0)); + } +} diff --git a/src/shaders/snow_light_accumulation.wesl b/src/shaders/snow_light_accumulation.wesl new file mode 100644 index 0000000..ea91cad --- /dev/null +++ b/src/shaders/snow_light_accumulation.wesl @@ -0,0 +1,117 @@ +import package::shared::{Spotlight, MAX_SPOTLIGHTS, calculate_spotlight_data}; + +struct AccumulationUniforms { + terrain_min_xz: vec2, + terrain_max_xz: vec2, + decay_rate: f32, + delta_time: f32, + spotlight_count: u32, + _padding: u32, + light_view_projection: mat4x4, + shadow_bias: f32, + terrain_height_scale: f32, + _padding3: f32, + _padding4: f32, + spotlights: array, +} + +@group(0) @binding(0) +var previous_light: texture_2d; + +@group(0) @binding(1) +var light_sampler: sampler; + +@group(0) @binding(2) +var uniforms: AccumulationUniforms; + +@group(0) @binding(3) +var heightmap: texture_2d; + +@group(0) @binding(4) +var heightmap_sampler: sampler; + +@group(0) @binding(5) +var shadow_map: texture_depth_2d; + +@group(0) @binding(6) +var shadow_sampler: sampler_comparison; + +@group(0) @binding(7) +var snow_depth: texture_2d; + +@group(0) @binding(8) +var snow_depth_sampler: sampler; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var out: VertexOutput; + + let x = f32((vertex_index << 1u) & 2u); + let y = f32(vertex_index & 2u); + out.position = vec4(x * 2.0 - 1.0, y * 2.0 - 1.0, 0.0, 1.0); + out.uv = vec2(x, 1.0 - y); + + return out; +} + +fn sample_shadow_map(light_space_pos: vec4) -> f32 { + let proj_coords = light_space_pos.xyz / light_space_pos.w; + let ndc_coords = proj_coords * vec3(0.5, -0.5, 1.0) + vec3(0.5, 0.5, 0.0); + + if ndc_coords.x < 0.0 || ndc_coords.x > 1.0 || + ndc_coords.y < 0.0 || ndc_coords.y > 1.0 || + ndc_coords.z < 0.0 || ndc_coords.z > 1.0 { + return 1.0; + } + + let depth = ndc_coords.z - uniforms.shadow_bias; + let shadow = textureSampleCompare(shadow_map, shadow_sampler, ndc_coords.xy, depth); + + return shadow; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let prev_light = textureSample(previous_light, light_sampler, in.uv).r; + + let world_xz = mix(uniforms.terrain_min_xz, uniforms.terrain_max_xz, in.uv); + let terrain_height = textureSampleLevel(heightmap, heightmap_sampler, in.uv, 0.0).r * uniforms.terrain_height_scale; + let depth = textureSampleLevel(snow_depth, snow_depth_sampler, in.uv, 0.0).r; + let snow_surface_height = terrain_height + depth; + let snow_surface_pos = vec3(world_xz.x, snow_surface_height, world_xz.y); + + let light_space_position = uniforms.light_view_projection * vec4(snow_surface_pos, 1.0); + let shadow = sample_shadow_map(light_space_position); + + var current_light = 0.0; + if shadow > 0.0 { + let tile_scale = 2.0; + let surface_normal = vec3(0.0, 1.0, 0.0); + + for (var i = 0u; i < uniforms.spotlight_count; i++) { + let spotlight = uniforms.spotlights[i]; + let data = calculate_spotlight_data(snow_surface_pos, surface_normal, spotlight, tile_scale, shadow); + let light = f32(data.is_lit); + current_light = max(current_light, light); + } + } + + var accumulated: f32; + if current_light > 0.01 { + accumulated = current_light; + } else { + let decay_factor = exp(-uniforms.decay_rate * uniforms.delta_time * 60.0); + accumulated = prev_light * decay_factor; + + if accumulated < 0.01 { + accumulated = 0.0; + } + } + + return vec4(accumulated, 0.0, 0.0, 1.0); +} diff --git a/src/shaders/standard.wesl b/src/shaders/standard.wesl new file mode 100644 index 0000000..147b448 --- /dev/null +++ b/src/shaders/standard.wesl @@ -0,0 +1,66 @@ +import package::shared::{VertexInput, VertexOutput, sample_shadow_map, flowmap_path_lighting_with_shadow, all_spotlights_lighting, uniforms, blue_noise_texture, blue_noise_sampler}; + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + var output: VertexOutput; + + let instance_model = mat4x4( + input.instance_model_0, + input.instance_model_1, + input.instance_model_2, + input.instance_model_3 + ); + + let world_pos = instance_model * vec4(input.position, 1.0); + output.world_position = world_pos.xyz; + output.clip_position = uniforms.projection * uniforms.view * world_pos; + + let normal_matrix = mat3x3( + instance_model[0].xyz, + instance_model[1].xyz, + instance_model[2].xyz + ); + output.world_normal = normalize(normal_matrix * input.normal); + + output.light_space_position = uniforms.light_view_projection * world_pos; + output.dissolve_amount = 0.0; + + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let shadow = sample_shadow_map(input.light_space_position); + + let debug = 0u; + + if debug == 3u { + return vec4(shadow, shadow, shadow, 1.0); + } + + if debug == 2u { + let proj_coords = input.light_space_position.xyz / input.light_space_position.w; + return vec4(proj_coords.x, proj_coords.y, proj_coords.z, 1.0); + } + + if debug == 1u { + let proj_coords = input.light_space_position.xyz / input.light_space_position.w; + let ndc_coords = proj_coords * vec3(0.5, -0.5, 1.0) + vec3(0.5, 0.5, 0.0); + let in_bounds = ndc_coords.x >= 0.0 && ndc_coords.x <= 1.0 && + ndc_coords.y >= 0.0 && ndc_coords.y <= 1.0 && + ndc_coords.z >= 0.0 && ndc_coords.z <= 1.0; + if in_bounds { + return vec4(ndc_coords.x, ndc_coords.y, ndc_coords.z, 1.0); + } else { + return vec4(0.0, 0.0, 0.0, 1.0); + } + } + + let tile_scale = 4.0; + let flowmap_strokes = flowmap_path_lighting_with_shadow(input.world_position, input.clip_position, input.world_normal, tile_scale, shadow); + let spotlight_strokes = all_spotlights_lighting(input.world_position, input.clip_position, input.world_normal, tile_scale, shadow); + // let brightness = max(flowmap_strokes, spotlight_strokes); + let brightness = 0.0; + + return vec4(brightness, brightness, brightness, 1.0); +} diff --git a/shaders/terrain.wgsl b/src/shaders/terrain.wesl similarity index 67% rename from shaders/terrain.wgsl rename to src/shaders/terrain.wesl index 30619cf..bfa0448 100644 --- a/shaders/terrain.wgsl +++ b/src/shaders/terrain.wesl @@ -1,3 +1,5 @@ +import package::shared::{VertexInput, VertexOutput, sample_shadow_map, flowmap_path_lighting_with_shadow, all_spotlights_lighting, uniforms, TERRAIN_BOUNDS, flowmap_texture, flowmap_sampler}; + @vertex fn vs_main(input: VertexInput) -> VertexOutput { var output: VertexOutput; @@ -29,6 +31,24 @@ fn vs_main(input: VertexInput) -> VertexOutput { fn fs_main(input: VertexOutput) -> @location(0) vec4 { let debug = 0u; + if debug == 4u { + let proj_coords = input.light_space_position.xyz / input.light_space_position.w; + let ndc_coords = proj_coords * vec3(0.5, -0.5, 1.0) + vec3(0.5, 0.5, 0.0); + let in_bounds = ndc_coords.x >= 0.0 && ndc_coords.x <= 1.0 && + ndc_coords.y >= 0.0 && ndc_coords.y <= 1.0 && + ndc_coords.z >= 0.0 && ndc_coords.z <= 1.0; + if in_bounds { + return vec4(ndc_coords.x, ndc_coords.y, ndc_coords.z, 1.0); + } else { + return vec4(0.0, 0.0, 0.0, 1.0); + } + } + + if debug == 3u { + let shadow = sample_shadow_map(input.light_space_position); + return vec4(shadow, shadow, shadow, 1.0); + } + if debug == 1u { let flowmap_uv = (vec2(input.world_position.x, input.world_position.z) + TERRAIN_BOUNDS * 0.5) / TERRAIN_BOUNDS; let flowmap_sample = textureSampleLevel(flowmap_texture, flowmap_sampler, flowmap_uv, 0.0).rgb; @@ -61,10 +81,9 @@ fn fs_main(input: VertexOutput) -> @location(0) vec4 { let shadow = sample_shadow_map(input.light_space_position); - let tile_scale = 1.0; - let flowmap_strokes = flowmap_path_lighting_with_shadow(input.world_position, input.world_normal, tile_scale, shadow); - let point_strokes = point_lighting_with_shadow(input.world_position, input.world_normal, vec3(0.0, 100.0, 0.0), tile_scale, shadow); - let brightness = max(flowmap_strokes, point_strokes); + let tile_scale = 2.0; + let spotlight_strokes = all_spotlights_lighting(input.world_position, input.clip_position, input.world_normal, tile_scale, shadow); + let brightness = spotlight_strokes; return vec4(brightness, brightness, brightness, 1.0); } diff --git a/src/snow.rs b/src/snow.rs new file mode 100644 index 0000000..aa75688 --- /dev/null +++ b/src/snow.rs @@ -0,0 +1,451 @@ +use std::rc::Rc; + +use exr::prelude::{ReadChannels, ReadLayers}; +use glam::{Vec2, Vec3}; +use wgpu::util::DeviceExt; + +use crate::{ + components::MeshComponent, + entity::EntityHandle, + mesh::{Mesh, Vertex}, + render, + world::{Transform, World}, +}; + +pub struct SnowConfig +{ + pub depth_map_path: String, + pub heightmap_path: String, + pub terrain_size: Vec2, + pub resolution: (u32, u32), +} + +impl SnowConfig +{ + pub fn new(depth_map_path: &str, heightmap_path: &str, terrain_size: Vec2, resolution: (u32, u32)) -> Self + { + Self { + depth_map_path: depth_map_path.to_string(), + heightmap_path: heightmap_path.to_string(), + terrain_size, + resolution, + } + } + + pub fn default() -> Self + { + Self { + depth_map_path: "textures/snow_depth.exr".to_string(), + heightmap_path: "textures/terrain_heightmap.exr".to_string(), + terrain_size: Vec2::new(1000.0, 1000.0), + resolution: (1000, 1000), + } + } +} + +pub struct SnowLayer +{ + pub entity: EntityHandle, + pub depth_texture: wgpu::Texture, + pub depth_texture_view: wgpu::TextureView, + pub depth_bind_group: wgpu::BindGroup, + pub width: u32, + pub height: u32, + pub deform_bind_group: wgpu::BindGroup, + pub deform_pipeline: wgpu::ComputePipeline, + pub deform_params_buffer: wgpu::Buffer, +} + +impl SnowLayer +{ + pub fn load(world: &mut World, config: &SnowConfig) -> anyhow::Result + { + println!("\n=== Loading Snow Layer ==="); + println!("Depth map path: {}", config.depth_map_path); + println!("Heightmap path: {}", config.heightmap_path); + println!("Terrain size: {:?}", config.terrain_size); + + let (depth_data, width, height) = Self::load_depth_map(&config.depth_map_path)?; + let (heightmap_data, hm_width, hm_height) = Self::load_depth_map(&config.heightmap_path)?; + + if width != hm_width || height != hm_height { + anyhow::bail!("Snow depth map ({}×{}) and heightmap ({}×{}) dimensions don't match!", + width, height, hm_width, hm_height); + } + + println!("Using EXR dimensions: {}×{}", width, height); + + let (depth_texture, depth_texture_view, depth_bind_group) = + Self::create_depth_texture(&depth_data, width, height); + + let mesh = Self::generate_snow_mesh(&depth_data, &heightmap_data, width, height, config.terrain_size); + let num_indices = mesh.num_indices; + + let entity = world.spawn(); + world.transforms.insert(entity, Transform::IDENTITY); + + if num_indices > 0 { + world.meshes.insert( + entity, + MeshComponent { + mesh: Rc::new(mesh), + pipeline: render::Pipeline::Snow, + instance_buffer: None, + num_instances: 1, + }, + ); + println!("Snow mesh created with {} indices", num_indices); + } else { + println!("⚠️ No snow mesh created - all depth values are zero"); + } + + let (deform_pipeline, deform_bind_group, deform_params_buffer) = + Self::create_deform_pipeline(&depth_texture_view); + + Ok(Self { + entity, + depth_texture, + depth_texture_view, + depth_bind_group, + width, + height, + deform_bind_group, + deform_pipeline, + deform_params_buffer, + }) + } + + fn load_depth_map(path: &str) -> anyhow::Result<(Vec, u32, u32)> + { + println!("Loading snow depth map from: {}", path); + + let image = exr::prelude::read() + .no_deep_data() + .largest_resolution_level() + .all_channels() + .all_layers() + .all_attributes() + .from_file(path)?; + + let layer = &image.layer_data[0]; + let width = layer.size.width() as u32; + let height = layer.size.height() as u32; + + println!(" Layer size: {}×{}", width, height); + println!(" Available channels: {:?}", layer.channel_data.list.iter().map(|c| &c.name).collect::>()); + + let channel = layer.channel_data.list.iter() + .find(|c| format!("{:?}", c.name).contains("\"R\"")) + .or_else(|| layer.channel_data.list.first()) + .ok_or_else(|| anyhow::anyhow!("No channels found in EXR"))?; + + println!(" Using channel: {:?}", channel.name); + + let depths: Vec = channel.sample_data.values_as_f32().collect(); + + let min_value = depths.iter().cloned().fold(f32::INFINITY, f32::min); + let max_value = depths.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + let avg_value = depths.iter().sum::() / depths.len() as f32; + let non_zero_count = depths.iter().filter(|&&v| v > 0.0001).count(); + + println!(" Total values: {}", depths.len()); + println!(" Min: {:.6}, Max: {:.6}, Avg: {:.6}", min_value, max_value, avg_value); + println!(" Non-zero values: {} ({:.1}%)", non_zero_count, (non_zero_count as f32 / depths.len() as f32) * 100.0); + + if max_value < 0.0001 { + println!(" ⚠️ WARNING: All values are effectively zero! Snow depth map may be invalid."); + } else { + println!(" ✓ Snow depth data loaded successfully"); + } + + Ok((depths, width, height)) + } + + fn create_depth_texture( + depth_data: &[f32], + width: u32, + height: u32, + ) -> (wgpu::Texture, wgpu::TextureView, wgpu::BindGroup) + { + render::with_device(|device| { + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Snow Depth Texture"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::STORAGE_BINDING, + view_formats: &[], + }); + + let data_bytes: &[u8] = bytemuck::cast_slice(depth_data); + + render::with_queue(|queue| { + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + data_bytes, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + size, + ); + }); + + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Snow Depth Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT | wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }], + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Snow Depth Bind Group"), + layout: &bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture_view), + }], + }); + + (texture, texture_view, bind_group) + }) + } + + fn generate_snow_mesh( + depth_data: &[f32], + heightmap_data: &[f32], + width: u32, + height: u32, + terrain_size: Vec2, + ) -> Mesh + { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + + let cell_size_x = terrain_size.x / (width - 1) as f32; + let cell_size_z = terrain_size.y / (height - 1) as f32; + let half_width = terrain_size.x / 2.0; + let half_height = terrain_size.y / 2.0; + + for z in 0..height + { + for x in 0..width + { + let index = (z * width + x) as usize; + let snow_depth = depth_data.get(index).copied().unwrap_or(0.0); + let terrain_height = heightmap_data.get(index).copied().unwrap_or(0.0); + + let world_x = x as f32 * cell_size_x - half_width; + let world_z = z as f32 * cell_size_z - half_height; + let world_y = terrain_height + snow_depth; + + vertices.push(Vertex { + position: [world_x, world_y, world_z], + normal: [0.0, 1.0, 0.0], + uv: [x as f32 / width as f32, z as f32 / height as f32], + }); + } + } + + for z in 0..(height - 1) + { + for x in 0..(width - 1) + { + let index = (z * width + x) as usize; + let depth_tl = depth_data.get(index).copied().unwrap_or(0.0); + let depth_tr = depth_data.get(index + 1).copied().unwrap_or(0.0); + let depth_bl = depth_data.get(index + width as usize).copied().unwrap_or(0.0); + let depth_br = depth_data + .get(index + width as usize + 1) + .copied() + .unwrap_or(0.0); + + if depth_tl > 0.001 + || depth_tr > 0.001 + || depth_bl > 0.001 + || depth_br > 0.001 + { + let vertex_index = (z * width + x) as u32; + + indices.push(vertex_index); + indices.push(vertex_index + width); + indices.push(vertex_index + 1); + + indices.push(vertex_index + 1); + indices.push(vertex_index + width); + indices.push(vertex_index + width + 1); + } + } + } + + let vertex_buffer = render::with_device(|device| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Snow Vertex Buffer"), + contents: bytemuck::cast_slice(&vertices), + usage: wgpu::BufferUsages::VERTEX, + }) + }); + + let index_buffer = render::with_device(|device| { + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Snow Index Buffer"), + contents: bytemuck::cast_slice(&indices), + usage: wgpu::BufferUsages::INDEX, + }) + }); + + Mesh { + vertex_buffer, + index_buffer, + num_indices: indices.len() as u32, + } + } + + fn create_deform_pipeline( + depth_texture_view: &wgpu::TextureView, + ) -> (wgpu::ComputePipeline, wgpu::BindGroup, wgpu::Buffer) + { + render::with_device(|device| { + let shader_source = std::fs::read_to_string("src/shaders/snow_deform.wgsl") + .expect("Failed to load snow deform shader"); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Snow Deform Shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let params_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Snow Deform Params"), + size: 32, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Snow Deform Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::StorageTexture { + access: wgpu::StorageTextureAccess::ReadWrite, + format: wgpu::TextureFormat::R32Float, + view_dimension: wgpu::TextureViewDimension::D2, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Snow Deform Bind Group"), + layout: &bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(depth_texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: params_buffer.as_entire_binding(), + }, + ], + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Snow Deform Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("Snow Deform Pipeline"), + layout: Some(&pipeline_layout), + module: &shader, + entry_point: Some("deform"), + compilation_options: Default::default(), + cache: None, + }); + + (pipeline, bind_group, params_buffer) + }) + } + + pub fn deform_at_position(&self, position: Vec3, radius: f32, depth: f32) + { + render::with_queue(|queue| { + let params_data = [position.x, position.z, radius, depth]; + let params_bytes: &[u8] = bytemuck::cast_slice(¶ms_data); + + queue.write_buffer(&self.deform_params_buffer, 0, params_bytes); + }); + + render::with_device(|device| { + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Snow Deform Encoder"), + }); + + { + let mut compute_pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("Snow Deform Pass"), + timestamp_writes: None, + }); + + compute_pass.set_pipeline(&self.deform_pipeline); + compute_pass.set_bind_group(0, &self.deform_bind_group, &[]); + + let workgroup_size = 16; + let dispatch_x = (self.width + workgroup_size - 1) / workgroup_size; + let dispatch_y = (self.height + workgroup_size - 1) / workgroup_size; + compute_pass.dispatch_workgroups(dispatch_x, dispatch_y, 1); + } + + render::with_queue(|queue| { + queue.submit(Some(encoder.finish())); + }); + }); + } + + #[allow(dead_code)] + pub fn regenerate_mesh(&mut self, _world: &mut World, _config: &SnowConfig) + { + todo!("Implement regenerate_mesh with correct wgpu types for texture-to-buffer copy"); + } +} diff --git a/src/snow_light.rs b/src/snow_light.rs new file mode 100644 index 0000000..7797536 --- /dev/null +++ b/src/snow_light.rs @@ -0,0 +1,550 @@ +use bytemuck::{Pod, Zeroable}; +use glam::{Vec2, Vec3}; +use wgpu::util::DeviceExt; + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct AccumulationUniforms +{ + terrain_min_xz: [f32; 2], + terrain_max_xz: [f32; 2], + decay_rate: f32, + delta_time: f32, + spotlight_count: u32, + _padding: u32, + light_view_projection: [[f32; 4]; 4], + shadow_bias: f32, + terrain_height_scale: f32, + _padding3: f32, + _padding4: f32, + spotlights: [crate::render::SpotlightRaw; crate::render::MAX_SPOTLIGHTS], +} + +pub struct SnowLightAccumulation +{ + texture_ping: wgpu::Texture, + texture_pong: wgpu::Texture, + view_ping: wgpu::TextureView, + view_pong: wgpu::TextureView, + + bind_group_layout: wgpu::BindGroupLayout, + bind_group_ping: Option, + bind_group_pong: Option, + + uniform_buffer: wgpu::Buffer, + pipeline: wgpu::RenderPipeline, + + quad_vb: wgpu::Buffer, + quad_ib: wgpu::Buffer, + quad_num_indices: u32, + + current: bool, + needs_clear: bool, + + terrain_min: Vec2, + terrain_max: Vec2, + pub decay_rate: f32, +} + +impl SnowLightAccumulation +{ + pub fn new( + device: &wgpu::Device, + terrain_min: Vec2, + terrain_max: Vec2, + resolution: u32, + ) -> Self + { + let size = wgpu::Extent3d { + width: resolution, + height: resolution, + depth_or_array_layers: 1, + }; + + let texture_desc = wgpu::TextureDescriptor { + label: Some("Snow Light Accumulation"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R16Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }; + + let texture_ping = device.create_texture(&texture_desc); + let texture_pong = device.create_texture(&texture_desc); + + let view_ping = texture_ping.create_view(&wgpu::TextureViewDescriptor::default()); + let view_pong = texture_pong.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Snow Light 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() + }); + + let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Snow Light Accumulation Uniforms"), + size: std::mem::size_of::() as wgpu::BufferAddress, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Snow Light Accumulation Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + 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: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 5, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 6, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 7, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 8, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering), + count: None, + }, + ], + }); + + let compiler = wesl::Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::snow_light_accumulation".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Snow Light Accumulation Shader"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Snow Light Accumulation Pipeline Layout"), + bind_group_layouts: &[&bind_group_layout], + push_constant_ranges: &[], + }); + + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Snow Light Accumulation Pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::R16Float, + blend: Some(wgpu::BlendState::REPLACE), + 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: None, + multisample: wgpu::MultisampleState { + count: 1, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview: None, + cache: None, + }); + + let vertices: &[f32] = &[ + -1.0, -1.0, 0.0, 1.0, + 3.0, -1.0, 2.0, 1.0, + -1.0, 3.0, 0.0, -1.0, + ]; + + let quad_vb = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Snow Light Quad VB"), + contents: bytemuck::cast_slice(vertices), + usage: wgpu::BufferUsages::VERTEX, + }); + + let indices: &[u16] = &[0, 1, 2]; + let quad_ib = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Snow Light Quad IB"), + contents: bytemuck::cast_slice(indices), + usage: wgpu::BufferUsages::INDEX, + }); + + Self { + texture_ping, + texture_pong, + view_ping, + view_pong, + bind_group_layout, + bind_group_ping: None, + bind_group_pong: None, + uniform_buffer, + pipeline, + quad_vb, + quad_ib, + quad_num_indices: 3, + current: false, + needs_clear: true, + terrain_min, + terrain_max, + decay_rate: 0.015, + } + } + + pub fn clear(&self, encoder: &mut wgpu::CommandEncoder) + { + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Clear Snow Light Ping"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.view_ping, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Clear Snow Light Pong"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.view_pong, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + } + + pub fn set_heightmap( + &mut self, + device: &wgpu::Device, + heightmap_view: &wgpu::TextureView, + heightmap_sampler: &wgpu::Sampler, + shadow_map_view: &wgpu::TextureView, + shadow_sampler: &wgpu::Sampler, + snow_depth_view: &wgpu::TextureView, + snow_depth_sampler: &wgpu::Sampler, + ) + { + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Snow Light 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.bind_group_ping = Some(device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Snow Light Accumulation Bind Group Ping"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self.view_pong), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(heightmap_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(heightmap_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(shadow_map_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(shadow_sampler), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(snow_depth_view), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(snow_depth_sampler), + }, + ], + })); + + self.bind_group_pong = Some(device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Snow Light Accumulation Bind Group Pong"), + layout: &self.bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&self.view_ping), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(heightmap_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(heightmap_sampler), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(shadow_map_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(shadow_sampler), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(snow_depth_view), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::Sampler(snow_depth_sampler), + }, + ], + })); + } + + pub fn render( + &mut self, + encoder: &mut wgpu::CommandEncoder, + queue: &wgpu::Queue, + spotlights: &[crate::render::Spotlight], + delta_time: f32, + light_view_projection: &glam::Mat4, + shadow_bias: f32, + terrain_height_scale: f32, + ) + { + if self.needs_clear + { + self.clear(encoder); + 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() + { + spotlight_array[i] = spotlight.to_raw(); + } + + let uniforms = AccumulationUniforms { + terrain_min_xz: self.terrain_min.to_array(), + 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, + _padding: 0, + light_view_projection: light_view_projection.to_cols_array_2d(), + shadow_bias, + terrain_height_scale, + _padding3: 0.0, + _padding4: 0.0, + spotlights: spotlight_array, + }; + + queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms])); + + let write_view = if self.current { &self.view_ping } else { &self.view_pong }; + let bind_group = if self.current { self.bind_group_ping.as_ref() } else { self.bind_group_pong.as_ref() }; + + let Some(bind_group) = bind_group else { + return; + }; + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Snow Light Accumulation Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: write_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + + render_pass.set_pipeline(&self.pipeline); + render_pass.set_bind_group(0, bind_group, &[]); + render_pass.set_vertex_buffer(0, self.quad_vb.slice(..)); + render_pass.set_index_buffer(self.quad_ib.slice(..), wgpu::IndexFormat::Uint16); + render_pass.draw_indexed(0..self.quad_num_indices, 0, 0..1); + } + + self.current = !self.current; + } + + pub fn read_view(&self) -> &wgpu::TextureView + { + if self.current { &self.view_pong } else { &self.view_ping } + } + + pub fn create_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout + { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Snow Persistent Light Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + 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: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }) + } + + pub fn create_read_bind_group( + &self, + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + ) -> wgpu::BindGroup + { + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Snow Persistent Light 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() + }); + + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Snow Persistent Light Bind Group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(self.read_view()), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }) + } +} diff --git a/src/space.rs b/src/space.rs new file mode 100644 index 0000000..4e68b61 --- /dev/null +++ b/src/space.rs @@ -0,0 +1,75 @@ +use anyhow::Result; +use glam::Vec3; + +use crate::{ + empty::Empties, + light::{LightData, Lights}, + mesh::{InstanceData, Mesh}, + render, +}; + +pub const CAMERA_SPAWN_OFFSET: Vec3 = Vec3::new(15.0, 15.0, 15.0); + +pub struct Space +{ + pub mesh_data: Vec<(Mesh, Vec)>, + pub spotlights: Vec, + pub player_spawn: Vec3, +} + +impl Space +{ + pub fn load_space(gltf_path: &str) -> Result + { + let mesh_data = render::with_device(|device| { + Mesh::load_gltf_with_instances(device, gltf_path) + })?; + + let lights = Lights::load_lights(gltf_path) + .map_err(|e| anyhow::anyhow!("Failed to load lights: {}", e))?; + + let spotlights = lights.into_spotlights(); + + let player_spawn = Self::get_player_spawn(gltf_path)?; + + Ok(Space { + mesh_data, + spotlights, + player_spawn, + }) + } + + fn get_player_spawn(gltf_path: &str) -> Result + { + let empty = Empties::get_empty_by_name(gltf_path, "PlayerSpawn")?; + + if let Some(empty_node) = empty + { + let (_scale, _rotation, translation) = empty_node.transform.to_scale_rotation_translation(); + Ok(translation) + } + else + { + println!("Warning: PlayerSpawn empty not found, using default position"); + Ok(Vec3::new(0.0, 5.0, 0.0)) + } + } + + pub fn camera_spawn_position(&self) -> Vec3 + { + self.player_spawn + CAMERA_SPAWN_OFFSET + } + + pub fn terrain_mesh(&self) -> Option<&Mesh> + { + self.mesh_data.first().map(|(mesh, _)| mesh) + } + + pub fn tree_instances(&self) -> Option<(&Mesh, &Vec)> + { + self.mesh_data + .iter() + .find(|(_, instances)| !instances.is_empty()) + .map(|(mesh, instances)| (mesh, instances)) + } +} diff --git a/src/systems/camera.rs b/src/systems/camera.rs index 4ba83c2..af41fd1 100644 --- a/src/systems/camera.rs +++ b/src/systems/camera.rs @@ -1,7 +1,8 @@ use glam::Vec3; +use crate::components::FollowComponent; use crate::utility::input::InputState; -use crate::world::World; +use crate::world::{Transform, World}; pub fn camera_input_system(world: &mut World, input_state: &InputState) { @@ -18,11 +19,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 - .camera_follows - .get(camera_entity) - .map(|f| f.is_following) - .unwrap_or(false); + let is_following = world.follows.get(camera_entity).is_some(); camera.yaw += input_state.mouse_delta.0 * 0.0008; @@ -45,26 +42,20 @@ pub fn camera_input_system(world: &mut World, input_state: &InputState) pub fn camera_follow_system(world: &mut World) { - let camera_entities: Vec<_> = world.camera_follows.all(); + let camera_entities: Vec<_> = world.follows.all(); for camera_entity in camera_entities { - if let Some(follow) = world.camera_follows.get(camera_entity) + if let Some(camera) = world.cameras.get(camera_entity) { - if !follow.is_following + if let Some(follow) = world.follows.get(camera_entity) { - continue; - } + let target_entity = follow.target; + let offset = follow.offset.position; - let target_entity = follow.target_entity; - let offset = follow.offset; - - if let Some(target_transform) = world.transforms.get(target_entity) - { - let target_position = target_transform.position; - - if let Some(camera) = world.cameras.get_mut(camera_entity) + if let Some(target_transform) = world.transforms.get(target_entity) { + let target_position = target_transform.position; let distance = offset.length(); let orbit_yaw = camera.yaw + std::f32::consts::PI; @@ -75,15 +66,15 @@ pub fn camera_follow_system(world: &mut World) let new_offset = Vec3::new(offset_x, offset_y, offset_z); - if let Some(camera_transform) = world.transforms.get_mut(camera_entity) - { - camera_transform.position = target_position + new_offset; - } + world + .transforms + .with_mut(camera_entity, |camera_transform| { + camera_transform.position = target_position + new_offset; + }); - if let Some(follow_mut) = world.camera_follows.get_mut(camera_entity) - { - follow_mut.offset = new_offset; - } + world.follows.components.get_mut(&camera_entity).map(|f| { + f.offset.position = new_offset; + }); } } } @@ -152,19 +143,16 @@ 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) { - if let Some(follow) = world.camera_follows.get_mut(camera_entity) + if let Some(camera_transform) = world.transforms.get(camera_entity) { - let target_entity = follow.target_entity; - - if let Some(target_transform) = world.transforms.get(target_entity) + let player_entities = world.player_tags.all(); + if let Some(&player_entity) = player_entities.first() { - if let Some(camera_transform) = world.transforms.get(camera_entity) + if let Some(target_transform) = world.transforms.get(player_entity) { let offset = camera_transform.position - target_transform.position; - follow.offset = offset; - follow.is_following = true; - let distance = offset.length(); + if distance > 0.0 { if let Some(camera) = world.cameras.get_mut(camera_entity) @@ -173,6 +161,16 @@ pub fn start_camera_following(world: &mut World, camera_entity: crate::entity::E camera.yaw = offset.z.atan2(offset.x) + std::f32::consts::PI; } } + + world.follows.insert( + camera_entity, + FollowComponent { + target: player_entity, + offset: Transform::from_position(offset), + inherit_rotation: false, + inherit_scale: false, + }, + ); } } } @@ -180,13 +178,13 @@ 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) { - if let Some(follow) = world.camera_follows.get_mut(camera_entity) + if let Some(follow) = world.follows.get(camera_entity) { - follow.is_following = false; + let target_entity = follow.target; if let Some(camera_transform) = world.transforms.get(camera_entity) { - if let Some(target_transform) = world.transforms.get(follow.target_entity) + if let Some(target_transform) = world.transforms.get(target_entity) { let look_direction = (target_transform.position - camera_transform.position).normalize(); @@ -198,5 +196,7 @@ pub fn stop_camera_following(world: &mut World, camera_entity: crate::entity::En } } } + + world.follows.remove(camera_entity); } } diff --git a/src/systems/follow.rs b/src/systems/follow.rs new file mode 100644 index 0000000..c9df64b --- /dev/null +++ b/src/systems/follow.rs @@ -0,0 +1,48 @@ +use crate::world::World; + +pub fn follow_system(world: &mut World) +{ + let following_entities: Vec<_> = world.follows.all(); + + for entity in following_entities + { + if let Some(follow) = world.follows.get(entity) + { + let target = follow.target; + if let Some(target_transform) = world.transforms.get(target) + { + let target_pos = target_transform.position; + let target_rot = target_transform.rotation; + let target_scale = target_transform.scale; + let offset = follow.offset; + let inherit_rot = follow.inherit_rotation; + let inherit_scale = follow.inherit_scale; + + world.transforms.with_mut(entity, |transform| { + transform.position = target_pos; + + if inherit_rot + { + let rotated_offset = target_rot * offset.position; + transform.position += rotated_offset; + transform.rotation = target_rot * offset.rotation; + } + else + { + transform.position += offset.position; + transform.rotation = offset.rotation; + } + + if inherit_scale + { + transform.scale = target_scale * offset.scale; + } + else + { + transform.scale = offset.scale; + } + }); + } + } + } +} diff --git a/src/systems/mod.rs b/src/systems/mod.rs index fc54fc6..c1ab09f 100644 --- a/src/systems/mod.rs +++ b/src/systems/mod.rs @@ -1,14 +1,22 @@ pub mod camera; +pub mod follow; pub mod input; pub mod physics_sync; pub mod render; +pub mod rotate; +pub mod spotlight_sync; pub mod state_machine; +pub mod tree_dissolve; pub use camera::{ camera_follow_system, camera_input_system, camera_noclip_system, start_camera_following, stop_camera_following, }; +pub use follow::follow_system; pub use input::player_input_system; pub use physics_sync::physics_sync_system; pub use render::render_system; +pub use rotate::rotate_system; +pub use spotlight_sync::spotlight_sync_system; pub use state_machine::{state_machine_physics_system, state_machine_system}; +pub use tree_dissolve::{tree_dissolve_update_system, tree_occlusion_system}; diff --git a/src/systems/render.rs b/src/systems/render.rs index 4dc7ee2..9c3a04c 100644 --- a/src/systems/render.rs +++ b/src/systems/render.rs @@ -2,8 +2,6 @@ use crate::mesh::InstanceRaw; use crate::render::DrawCall; use crate::world::World; use bytemuck::cast_slice; -use glam::Mat4; -use wgpu::util::DeviceExt; pub fn render_system(world: &World) -> Vec { @@ -24,16 +22,27 @@ pub fn render_system(world: &World) -> Vec } else { + let dissolve_amount = world.dissolves.get(entity).map(|d| d.amount).unwrap_or(0.0); + let instance_data = InstanceRaw { model: model_matrix.to_cols_array_2d(), + dissolve_amount, + _padding: [0.0; 3], }; let buffer = crate::render::with_device(|device| { - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Instance Buffer"), - contents: cast_slice(&[instance_data]), - usage: wgpu::BufferUsages::VERTEX, - }) + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + crate::render::with_queue(|queue| { + queue.write_buffer(&buffer, 0, cast_slice(&[instance_data])); + }); + + buffer }); (Some(buffer), 1) diff --git a/src/systems/rotate.rs b/src/systems/rotate.rs new file mode 100644 index 0000000..353ec67 --- /dev/null +++ b/src/systems/rotate.rs @@ -0,0 +1,20 @@ +use glam::Quat; + +use crate::world::World; + +pub fn rotate_system(world: &mut World, delta: f32) +{ + let entities = world.rotates.all(); + + for entity in entities + { + if let Some(rotate) = world.rotates.get(entity) + { + let rotation_delta = Quat::from_axis_angle(rotate.axis, rotate.speed * delta); + + world.transforms.with_mut(entity, |transform| { + transform.rotation = rotation_delta * transform.rotation; + }); + } + } +} diff --git a/src/systems/spotlight_sync.rs b/src/systems/spotlight_sync.rs new file mode 100644 index 0000000..fa57e3f --- /dev/null +++ b/src/systems/spotlight_sync.rs @@ -0,0 +1,32 @@ +use crate::render::Spotlight; +use crate::world::World; + +pub fn spotlight_sync_system(world: &World) -> Vec +{ + let mut entities = world.spotlights.all(); + entities.sort(); + + let mut spotlights = Vec::new(); + + for entity in entities + { + if let Some(spotlight_component) = world.spotlights.get(entity) + { + if let Some(transform) = world.transforms.get(entity) + { + let position = transform.position + spotlight_component.offset; + let direction = transform.rotation * spotlight_component.direction; + + spotlights.push(Spotlight::new( + position, + direction, + spotlight_component.inner_angle, + spotlight_component.outer_angle, + spotlight_component.range, + )); + } + } + } + + spotlights +} diff --git a/src/systems/tree_dissolve.rs b/src/systems/tree_dissolve.rs new file mode 100644 index 0000000..90d9df0 --- /dev/null +++ b/src/systems/tree_dissolve.rs @@ -0,0 +1,112 @@ +use crate::components::DissolveComponent; +use crate::world::World; +use glam::Vec3; + +pub fn tree_dissolve_update_system(world: &mut World, delta: f32) +{ + for entity in world.dissolves.all() + { + if let Some(dissolve) = world.dissolves.get_mut(entity) + { + let diff = dissolve.target_amount - dissolve.amount; + dissolve.amount += diff * dissolve.transition_speed * delta; + dissolve.amount = dissolve.amount.clamp(0.0, 1.0); + } + } +} + +pub fn tree_occlusion_system(world: &mut World) +{ + 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)); + + if let Some(player_pos) = player_pos + { + let camera_entity = world.cameras.get_active().map(|(e, _)| e); + let camera_pos = camera_entity.and_then(|e| world.transforms.get(e).map(|t| t.position)); + + let tree_count = world.tree_tags.all().len(); + if tree_count > 0 + { + static mut FRAME_COUNT: u32 = 0; + unsafe { + FRAME_COUNT += 1; + if FRAME_COUNT % 60 == 0 + { + println!("Tree occlusion system: {} trees detected", tree_count); + } + } + } + + if let Some(camera_pos) = camera_pos + { + let to_player = player_pos - camera_pos; + let distance_to_player = to_player.length(); + + if distance_to_player < 0.01 + { + return; + } + + let to_player_normalized = to_player.normalize(); + + for tree_entity in world.tree_tags.all() + { + if let Some(tree_transform) = world.transforms.get(tree_entity) + { + let tree_pos = tree_transform.position; + let to_tree = tree_pos - camera_pos; + let distance_to_tree = to_tree.length(); + + if distance_to_tree < distance_to_player + { + let projection = to_tree.dot(to_player_normalized); + + if projection > 0.0 + { + let perpendicular_vec = to_tree - to_player_normalized * projection; + let perp_distance = perpendicular_vec.length(); + + let occlusion_radius = 2.5; + + if perp_distance < occlusion_radius + { + let dissolve_amount = + 1.0 - (perp_distance / occlusion_radius).clamp(0.0, 1.0); + + static mut DEBUG_FRAME: u32 = 0; + unsafe { + DEBUG_FRAME += 1; + if DEBUG_FRAME % 60 == 0 + { + println!( + "Tree occluding! perp_dist: {:.2}, dissolve: {:.2}", + perp_distance, dissolve_amount + ); + } + } + + if let Some(dissolve) = world.dissolves.get_mut(tree_entity) + { + dissolve.target_amount = dissolve_amount; + } + else + { + let mut dissolve = DissolveComponent::new(); + dissolve.target_amount = dissolve_amount; + world.dissolves.insert(tree_entity, dissolve); + } + continue; + } + } + } + + if let Some(dissolve) = world.dissolves.get_mut(tree_entity) + { + dissolve.target_amount = 0.0; + } + } + } + } + } +} diff --git a/src/terrain.rs b/src/terrain.rs index cabb751..c63ac5c 100644 --- a/src/terrain.rs +++ b/src/terrain.rs @@ -7,11 +7,12 @@ use rapier3d::{ math::Isometry, prelude::{ColliderBuilder, RigidBodyBuilder}, }; +use wesl::Wesl; use crate::{ components::{MeshComponent, PhysicsComponent}, entity::EntityHandle, - mesh::{InstanceRaw, Mesh, Vertex}, + mesh::{InstanceData, InstanceRaw, Mesh, Vertex}, physics::PhysicsManager, render, world::{Transform, World}, @@ -39,7 +40,7 @@ impl TerrainConfig { Self { gltf_path: "meshes/terrain.gltf".to_string(), - heightmap_path: "textures/terrain.exr".to_string(), + heightmap_path: "textures/terrain_heightmap.exr".to_string(), size: Vec2::new(1000.0, 1000.0), } } @@ -49,111 +50,102 @@ pub struct Terrain; impl Terrain { - pub fn spawn(world: &mut World, config: &TerrainConfig) -> anyhow::Result + pub fn spawn( + world: &mut World, + mesh_data: Vec<(Mesh, Vec)>, + config: &TerrainConfig, + ) -> anyhow::Result { - let gltf_data = render::with_device(|device| { - Mesh::load_gltf_with_instances(device, &config.gltf_path) - })?; - - let terrain_entity = world.spawn(); let transform = Transform::IDENTITY; + let mut first_entity = None; + let mut physics_added = false; - let mut terrain_mesh = None; - let mut tree_mesh = None; - let mut tree_instances = None; - - for (mesh, instances) in gltf_data + for (mesh, instances) in mesh_data { + let entity = world.spawn(); + + if first_entity.is_none() + { + first_entity = Some(entity); + } + if instances.is_empty() { - if terrain_mesh.is_none() + world.transforms.insert(entity, transform); + world.meshes.insert( + entity, + MeshComponent { + mesh: Rc::new(mesh), + pipeline: render::Pipeline::Terrain, + instance_buffer: None, + num_instances: 1, + }, + ); + + if !physics_added { - terrain_mesh = Some(mesh); + let heights = Self::load_heightfield_from_exr(&config.heightmap_path)?; + + let height_scale = 1.0; + let scale = vector![config.size.x, height_scale, config.size.y]; + + let body = RigidBodyBuilder::fixed() + .translation(transform.get_position().into()) + .build(); + + let rigidbody_handle = PhysicsManager::add_rigidbody(body); + + let collider = ColliderBuilder::heightfield(heights.clone(), scale).build(); + + let collider_handle = + PhysicsManager::add_collider(collider, Some(rigidbody_handle)); + + PhysicsManager::set_heightfield_data( + heights, + scale, + transform.get_position().into(), + ); + + world.physics.insert( + entity, + PhysicsComponent { + rigidbody: rigidbody_handle, + collider: Some(collider_handle), + }, + ); + + physics_added = true; } } else { - tree_mesh = Some(mesh); - tree_instances = Some(instances); + let num_instances = instances.len(); + + let instance_raw: Vec = instances.iter().map(|i| i.to_raw()).collect(); + + let instance_buffer = render::with_device(|device| { + use wgpu::util::DeviceExt; + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Tree Instance Buffer"), + contents: bytemuck::cast_slice(&instance_raw), + usage: wgpu::BufferUsages::VERTEX, + }) + }); + + world.transforms.insert(entity, Transform::IDENTITY); + world.meshes.insert( + entity, + MeshComponent { + mesh: Rc::new(mesh), + pipeline: render::Pipeline::Environment, + instance_buffer: Some(instance_buffer), + num_instances: num_instances as u32, + }, + ); } } - if let Some(terrain_mesh) = terrain_mesh - { - world.transforms.insert(terrain_entity, transform); - world.meshes.insert( - terrain_entity, - MeshComponent { - mesh: Rc::new(terrain_mesh), - pipeline: render::Pipeline::Terrain, - instance_buffer: None, - num_instances: 1, - }, - ); - - let heights = Self::load_heightfield_from_exr(&config.heightmap_path)?; - - println!( - "Loaded terrain: {} rows × {} cols heightfield from EXR", - heights.nrows(), - heights.ncols() - ); - - let height_scale = 1.0; - let scale = vector![config.size.x, height_scale, config.size.y]; - - let body = RigidBodyBuilder::fixed() - .translation(transform.get_position().into()) - .build(); - - let rigidbody_handle = PhysicsManager::add_rigidbody(body); - - let collider = ColliderBuilder::heightfield(heights.clone(), scale).build(); - - let collider_handle = PhysicsManager::add_collider(collider, Some(rigidbody_handle)); - - PhysicsManager::set_heightfield_data(heights, scale, transform.get_position().into()); - - world.physics.insert( - terrain_entity, - PhysicsComponent { - rigidbody: rigidbody_handle, - collider: Some(collider_handle), - }, - ); - } - - if let (Some(tree_mesh), Some(instances)) = (tree_mesh, tree_instances) - { - let num_instances = instances.len(); - println!("Loaded {} tree instances", num_instances); - - let tree_entity = world.spawn(); - - let instance_raw: Vec = instances.iter().map(|i| i.to_raw()).collect(); - - let instance_buffer = render::with_device(|device| { - use wgpu::util::DeviceExt; - device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("Tree Instance Buffer"), - contents: bytemuck::cast_slice(&instance_raw), - usage: wgpu::BufferUsages::VERTEX, - }) - }); - - world.transforms.insert(tree_entity, Transform::IDENTITY); - world.meshes.insert( - tree_entity, - MeshComponent { - mesh: Rc::new(tree_mesh), - pipeline: render::Pipeline::Render, - instance_buffer: Some(instance_buffer), - num_instances: num_instances as u32, - }, - ); - } - - Ok(terrain_entity) + first_entity.ok_or_else(|| anyhow::anyhow!("No meshes found in glTF file")) } fn load_heightfield_from_exr(path: &str) -> anyhow::Result> @@ -184,11 +176,12 @@ pub fn create_terrain_render_pipeline( bind_group_layout: &wgpu::BindGroupLayout, ) -> wgpu::RenderPipeline { - let shared_source = - std::fs::read_to_string("shaders/shared.wgsl").expect("Failed to read shared shader"); - let terrain_source = - std::fs::read_to_string("shaders/terrain.wgsl").expect("Failed to read terrain shader"); - let shader_source = format!("{}\n{}", shared_source, terrain_source); + let compiler = Wesl::new("src/shaders"); + let shader_source = compiler + .compile(&"package::terrain".parse().unwrap()) + .inspect_err(|e| eprintln!("WESL error: {e}")) + .unwrap() + .to_string(); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Terrain Shader"), diff --git a/src/texture_loader.rs b/src/texture.rs similarity index 71% rename from src/texture_loader.rs rename to src/texture.rs index b30eaac..9e82565 100644 --- a/src/texture_loader.rs +++ b/src/texture.rs @@ -1,6 +1,5 @@ use anyhow::Result; use exr::prelude::{ReadChannels, ReadLayers}; -use half::f16; pub struct DitherTextures { @@ -16,6 +15,13 @@ pub struct FlowmapTexture pub sampler: wgpu::Sampler, } +pub struct HeightmapTexture +{ + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, + pub sampler: wgpu::Sampler, +} + impl DitherTextures { pub fn load_octaves(device: &wgpu::Device, queue: &wgpu::Queue) -> Result @@ -186,16 +192,11 @@ impl FlowmapTexture mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba16Float, + format: wgpu::TextureFormat::Rgba32Float, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); - let rgba_data_f16: Vec = rgba_data - .iter() - .map(|&f| f16::from_f32(f).to_bits()) - .collect(); - queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &texture, @@ -203,10 +204,10 @@ impl FlowmapTexture origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, - bytemuck::cast_slice(&rgba_data_f16), + bytemuck::cast_slice(&rgba_data), wgpu::TexelCopyBufferLayout { offset: 0, - bytes_per_row: Some(8 * width as u32), + bytes_per_row: Some(16 * width as u32), rows_per_image: Some(height as u32), }, wgpu::Extent3d { @@ -223,8 +224,8 @@ impl FlowmapTexture address_mode_u: wgpu::AddressMode::Repeat, address_mode_v: wgpu::AddressMode::Repeat, address_mode_w: wgpu::AddressMode::Repeat, - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, mipmap_filter: wgpu::FilterMode::Nearest, ..Default::default() }); @@ -236,3 +237,76 @@ impl FlowmapTexture }) } } + +impl HeightmapTexture +{ + pub fn load(device: &wgpu::Device, queue: &wgpu::Queue, path: &str) -> Result + { + let image = exr::prelude::read() + .no_deep_data() + .largest_resolution_level() + .all_channels() + .all_layers() + .all_attributes() + .from_file(path)?; + + let layer = &image.layer_data[0]; + let width = layer.size.width(); + let height = layer.size.height(); + + let channel = &layer.channel_data.list[0]; + let height_data: Vec = channel.sample_data.values_as_f32().collect(); + + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Heightmap Texture"), + size: wgpu::Extent3d { + width: width as u32, + height: height as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + 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, + }, + bytemuck::cast_slice(&height_data), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * width as u32), + rows_per_image: Some(height as u32), + }, + wgpu::Extent3d { + width: width as u32, + height: height as u32, + depth_or_array_layers: 1, + }, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Heightmap Sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Nearest, + min_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Ok(Self { + texture, + view, + sampler, + }) + } +} diff --git a/src/utility/transform.rs b/src/utility/transform.rs index fdfa30b..7118006 100644 --- a/src/utility/transform.rs +++ b/src/utility/transform.rs @@ -22,6 +22,16 @@ impl Transform Self::IDENTITY.translated(position) } + pub fn from_matrix(matrix: Mat4) -> Self + { + let (scale, rotation, position) = matrix.to_scale_rotation_translation(); + Self { + position, + rotation, + scale, + } + } + pub fn to_matrix(&self) -> Mat4 { Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.position) diff --git a/src/world.rs b/src/world.rs index 04696b7..0e1cb47 100644 --- a/src/world.rs +++ b/src/world.rs @@ -1,9 +1,12 @@ use std::collections::HashMap; +use crate::components::dissolve::DissolveComponent; +use crate::components::follow::FollowComponent; use crate::components::jump::JumpComponent; +use crate::components::lights::spot::SpotlightComponent; use crate::components::{ - CameraComponent, CameraFollowComponent, InputComponent, MeshComponent, MovementComponent, - PhysicsComponent, + CameraComponent, InputComponent, MeshComponent, MovementComponent, PhysicsComponent, + RotateComponent, }; use crate::entity::{EntityHandle, EntityManager}; use crate::state::StateMachine; @@ -415,12 +418,12 @@ impl CameraStorage } } -pub struct CameraFollowStorage +pub struct SpotlightStorage { - pub components: HashMap, + pub components: HashMap, } -impl CameraFollowStorage +impl SpotlightStorage { pub fn new() -> Self { @@ -429,26 +432,159 @@ impl CameraFollowStorage } } - pub fn insert(&mut self, entity: EntityHandle, component: CameraFollowComponent) + pub fn insert(&mut self, entity: EntityHandle, component: SpotlightComponent) { self.components.insert(entity, component); } - pub fn get(&self, entity: EntityHandle) -> Option<&CameraFollowComponent> + pub fn get(&self, entity: EntityHandle) -> Option<&SpotlightComponent> { self.components.get(&entity) } - pub fn get_mut(&mut self, entity: EntityHandle) -> Option<&mut CameraFollowComponent> + pub fn get_mut(&mut self, entity: EntityHandle) -> Option<&mut SpotlightComponent> { self.components.get_mut(&entity) } - pub fn with_mut(&mut self, entity: EntityHandle, f: F) -> Option - where - F: FnOnce(&mut CameraFollowComponent) -> R, + pub fn remove(&mut self, entity: EntityHandle) { - self.components.get_mut(&entity).map(f) + self.components.remove(&entity); + } + + pub fn all(&self) -> Vec + { + self.components.keys().copied().collect() + } +} + +pub struct TreeTagStorage +{ + pub components: HashMap, +} + +impl TreeTagStorage +{ + pub fn new() -> Self + { + Self { + components: HashMap::new(), + } + } + + pub fn insert(&mut self, entity: EntityHandle) + { + self.components.insert(entity, ()); + } + + pub fn remove(&mut self, entity: EntityHandle) + { + self.components.remove(&entity); + } + + pub fn all(&self) -> Vec + { + self.components.keys().copied().collect() + } +} + +pub struct DissolveStorage +{ + pub components: HashMap, +} + +impl DissolveStorage +{ + pub fn new() -> Self + { + Self { + components: HashMap::new(), + } + } + + pub fn insert(&mut self, entity: EntityHandle, component: DissolveComponent) + { + self.components.insert(entity, component); + } + + pub fn get(&self, entity: EntityHandle) -> Option<&DissolveComponent> + { + self.components.get(&entity) + } + + pub fn get_mut(&mut self, entity: EntityHandle) -> Option<&mut DissolveComponent> + { + self.components.get_mut(&entity) + } + + pub fn remove(&mut self, entity: EntityHandle) + { + self.components.remove(&entity); + } + + pub fn all(&self) -> Vec + { + self.components.keys().copied().collect() + } +} + +pub struct FollowStorage +{ + pub components: HashMap, +} + +impl FollowStorage +{ + pub fn new() -> Self + { + Self { + components: HashMap::new(), + } + } + + pub fn insert(&mut self, entity: EntityHandle, component: FollowComponent) + { + self.components.insert(entity, component); + } + + pub fn get(&self, entity: EntityHandle) -> Option<&FollowComponent> + { + self.components.get(&entity) + } + + pub fn remove(&mut self, entity: EntityHandle) + { + self.components.remove(&entity); + } + + pub fn all(&self) -> Vec + { + self.components.keys().copied().collect() + } +} + +pub struct RotateStorage +{ + pub components: HashMap, +} + +impl RotateStorage +{ + pub fn new() -> Self + { + Self { + components: HashMap::new(), + } + } + + pub fn insert(&mut self, entity: EntityHandle, component: RotateComponent) + { + self.components.insert(entity, component); + } + + pub fn get(&self, entity: EntityHandle) -> Option<&RotateComponent> + { + self.components.get(&entity) } pub fn remove(&mut self, entity: EntityHandle) @@ -474,7 +610,11 @@ pub struct World pub player_tags: PlayerTagStorage, pub state_machines: StateMachineStorage, pub cameras: CameraStorage, - pub camera_follows: CameraFollowStorage, + pub spotlights: SpotlightStorage, + pub tree_tags: TreeTagStorage, + pub dissolves: DissolveStorage, + pub follows: FollowStorage, + pub rotates: RotateStorage, } impl World @@ -492,7 +632,11 @@ impl World player_tags: PlayerTagStorage::new(), state_machines: StateMachineStorage::new(), cameras: CameraStorage::new(), - camera_follows: CameraFollowStorage::new(), + spotlights: SpotlightStorage::new(), + tree_tags: TreeTagStorage::new(), + dissolves: DissolveStorage::new(), + follows: FollowStorage::new(), + rotates: RotateStorage::new(), } } @@ -512,7 +656,11 @@ impl World self.player_tags.remove(entity); self.state_machines.remove(entity); self.cameras.remove(entity); - self.camera_follows.remove(entity); + self.spotlights.remove(entity); + self.tree_tags.remove(entity); + self.dissolves.remove(entity); + self.follows.remove(entity); + self.rotates.remove(entity); self.entities.despawn(entity); } } diff --git a/textures/blue_noise.png b/textures/blue_noise.png new file mode 100644 index 0000000..e217743 Binary files /dev/null and b/textures/blue_noise.png differ diff --git a/textures/scripts/README.md b/textures/scripts/README.md new file mode 100644 index 0000000..c62d685 --- /dev/null +++ b/textures/scripts/README.md @@ -0,0 +1,62 @@ +# Texture Generation Scripts + +## Blue Noise Generator + +`generate_blue_noise.py` - Generates blue noise textures for high-quality dithering effects. + +### Requirements + +```bash +pip install numpy pillow scipy +``` + +### Usage + +Basic usage (generates 128x128 texture): +```bash +python generate_blue_noise.py +``` + +Custom size: +```bash +python generate_blue_noise.py --width 256 --height 256 +``` + +Custom output path: +```bash +python generate_blue_noise.py --output ../my_blue_noise.png +``` + +Advanced options: +```bash +python generate_blue_noise.py --width 128 --height 128 --sigma 1.5 --method void_cluster +``` + +### Parameters + +- `--width`: Texture width in pixels (default: 128) +- `--height`: Texture height in pixels (default: 128) +- `--method`: Generation method + - `void_cluster`: High-quality void-and-cluster method (default, recommended) + - `annealing`: Simulated annealing method (slower) +- `--sigma`: Gaussian kernel sigma for void_cluster method (default: 1.5) + - Lower values (0.8-1.2): Tighter clustering, more high-frequency + - Higher values (2.0-3.0): Smoother distribution +- `--iterations`: Number of iterations (optional, auto-calculated if not specified) +- `--output`: Output file path (default: ../blue_noise.png) + +### What is Blue Noise? + +Blue noise is a type of noise with energy concentrated in high frequencies and minimal low-frequency content. This makes it ideal for dithering because: + +- No visible patterns or clustering +- Smooth gradients without banding +- Perceptually pleasing distribution +- Better than Bayer or white noise for transparency effects + +### Use Cases in snow_trail_sdl + +- **Tree dissolve effect**: Dither trees between camera and player for unobstructed view +- **Temporal effects**: Screen-space dithering for transitions +- **Transparency**: High-quality alpha dithering +- **LOD transitions**: Smooth fade between detail levels diff --git a/textures/scripts/generate_blue_noise.py b/textures/scripts/generate_blue_noise.py new file mode 100755 index 0000000..88c2a9c --- /dev/null +++ b/textures/scripts/generate_blue_noise.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 + +import numpy as np +from PIL import Image +import argparse +from pathlib import Path + +def gaussian_kernel(size, sigma): + x = np.arange(-size // 2 + 1, size // 2 + 1) + y = np.arange(-size // 2 + 1, size // 2 + 1) + xx, yy = np.meshgrid(x, y) + kernel = np.exp(-(xx**2 + yy**2) / (2 * sigma**2)) + return kernel / kernel.sum() + +def apply_periodic_filter(binary_pattern, kernel): + from scipy.signal import fftconvolve + return fftconvolve(binary_pattern, kernel, mode='same') + +def generate_blue_noise_void_cluster(width, height, sigma=1.5, iterations=None): + if iterations is None: + iterations = width * height + + size = width * height + pattern = np.zeros((height, width), dtype=np.float32) + + kernel_size = int(6 * sigma) + if kernel_size % 2 == 0: + kernel_size += 1 + kernel = gaussian_kernel(kernel_size, sigma) + + initial_pattern = np.random.rand(height, width) + + print(f"Generating {width}x{height} blue noise texture...") + print(f"Kernel size: {kernel_size}x{kernel_size}, sigma: {sigma}") + + dither_array = np.zeros(size, dtype=np.int32) + binary_pattern = np.zeros((height, width), dtype=np.float32) + + for i in range(size): + if i % (size // 10) == 0: + print(f"Progress: {i}/{size} ({100*i//size}%)") + + filtered = apply_periodic_filter(binary_pattern, kernel) + + if i < size // 2: + initial_energy = initial_pattern + filtered + coords = np.unravel_index(np.argmax(initial_energy), initial_energy.shape) + else: + coords = np.unravel_index(np.argmin(filtered), filtered.shape) + + dither_array[i] = coords[0] * width + coords[1] + binary_pattern[coords[0], coords[1]] = 1.0 + + print("Converting to threshold map...") + + threshold_map = np.zeros((height, width), dtype=np.float32) + for rank, pos in enumerate(dither_array): + y = pos // width + x = pos % width + threshold_map[y, x] = rank / size + + print("Done!") + return threshold_map + +def generate_blue_noise_simulated_annealing(width, height, iterations=10000): + print(f"Generating {width}x{height} blue noise using simulated annealing...") + + pattern = np.random.rand(height, width) + + def energy(pattern): + fft = np.fft.fft2(pattern) + power = np.abs(fft) ** 2 + + h, w = pattern.shape + cy, cx = h // 2, w // 2 + y, x = np.ogrid[:h, :w] + dist = np.sqrt((x - cx)**2 + (y - cy)**2) + + low_freq_mask = dist < min(h, w) * 0.1 + low_freq_energy = np.sum(power * low_freq_mask) + + return low_freq_energy + + current_energy = energy(pattern) + temperature = 1.0 + cooling_rate = 0.9995 + + for i in range(iterations): + if i % (iterations // 10) == 0: + print(f"Iteration {i}/{iterations}, Energy: {current_energy:.2f}, Temp: {temperature:.4f}") + + y1, x1 = np.random.randint(0, height), np.random.randint(0, width) + y2, x2 = np.random.randint(0, height), np.random.randint(0, width) + + pattern[y1, x1], pattern[y2, x2] = pattern[y2, x2], pattern[y1, x1] + + new_energy = energy(pattern) + delta_energy = new_energy - current_energy + + if delta_energy < 0 or np.random.rand() < np.exp(-delta_energy / temperature): + current_energy = new_energy + else: + pattern[y1, x1], pattern[y2, x2] = pattern[y2, x2], pattern[y1, x1] + + temperature *= cooling_rate + + print("Done!") + return pattern + +def main(): + parser = argparse.ArgumentParser(description='Generate blue noise texture for dithering') + parser.add_argument('--width', type=int, default=128, help='Texture width (default: 128)') + parser.add_argument('--height', type=int, default=128, help='Texture height (default: 128)') + parser.add_argument('--method', choices=['void_cluster', 'annealing'], default='void_cluster', + help='Generation method (default: void_cluster)') + parser.add_argument('--sigma', type=float, default=1.5, + help='Gaussian kernel sigma for void_cluster method (default: 1.5)') + parser.add_argument('--iterations', type=int, default=None, + help='Number of iterations (optional)') + parser.add_argument('--output', type=str, default='../blue_noise.png', + help='Output file path (default: ../blue_noise.png)') + + args = parser.parse_args() + + try: + from scipy.signal import fftconvolve + except ImportError: + print("Error: scipy is required for this script.") + print("Install it with: pip install scipy") + return + + if args.method == 'void_cluster': + noise = generate_blue_noise_void_cluster(args.width, args.height, args.sigma, args.iterations) + else: + noise = generate_blue_noise_simulated_annealing(args.width, args.height, + args.iterations or 10000) + + noise_normalized = ((noise - noise.min()) / (noise.max() - noise.min()) * 255).astype(np.uint8) + + img = Image.fromarray(noise_normalized, mode='L') + + output_path = Path(__file__).parent / args.output + output_path.parent.mkdir(parents=True, exist_ok=True) + img.save(output_path) + + print(f"\nBlue noise texture saved to: {output_path}") + print(f"Size: {args.width}x{args.height}") + print(f"Method: {args.method}") + + fft = np.fft.fft2(noise) + power_spectrum = np.abs(np.fft.fftshift(fft)) ** 2 + print(f"Power spectrum range: {power_spectrum.min():.2e} - {power_spectrum.max():.2e}") + +if __name__ == '__main__': + main() diff --git a/textures/snow_depth.exr b/textures/snow_depth.exr new file mode 100644 index 0000000..7394506 Binary files /dev/null and b/textures/snow_depth.exr differ diff --git a/textures/terrain_heightmap.exr b/textures/terrain_heightmap.exr new file mode 100644 index 0000000..3bd76c5 Binary files /dev/null and b/textures/terrain_heightmap.exr differ