1028 lines
38 KiB
Markdown
1028 lines
38 KiB
Markdown
# 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
|