38 KiB
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:
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
- Generated by
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
- Select your terrain mesh
- Go to Modifiers panel
- Add → Geometry Nodes modifier
- Click New to create a new node tree
- 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):
-
Normal node (Input → Normal)
- Outputs the surface normal vector at each vertex
-
Combine XYZ node (Utilities → Combine XYZ)
- X:
0.0 - Y:
0.0 - Z:
1.0 - This creates the "up" vector (world Z-axis)
- X:
-
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)
-
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:
- In Outliner, create collection:
Snow Blockers - Add static blocking objects (rocks, cliffs, buildings)
- Do NOT put instanced trees here - we'll handle those separately
- Keep terrain mesh OUT of this collection
Option A: Using Collection (For Static Objects)
Add Nodes:
-
Collection Info node (Input → Collection Info)
- Collection: Select
Snow Blockers - Separate Children: Disabled
- Reset Children: Disabled
- Transform Space: Original
- Collection: Select
-
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!
-
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:
-
On your terrain mesh, ensure the tree-spawning modifier comes BEFORE the snow modifier in the stack
-
In the snow Geometry Nodes tree, use the Group Input geometry
- The input geometry includes all previous modifiers' results
- This includes your instanced trees!
-
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:
-
Group Input → Fork this into two paths:
- Path A: Your terrain mesh (for snow calculation)
- Path B: Extract instances for raycast targets
-
Realize Instances node
- Connect: Group Input → Geometry
- This realizes ALL instances including trees from previous modifiers
- Output becomes our raycast target
-
Collection Info node (if using additional blockers)
- Collection:
Snow Blockers
- Collection:
-
Realize Instances node (for collection)
- Connect: Collection Info (Instances) → Geometry
-
Join Geometry node
- Socket 0: Realized instances from Group Input
- Socket 1: Realized instances from Collection
- Output: Combined blocking geometry
-
Position node (Input → Position)
- Source position for rays
-
Combine XYZ node (if not already created)
- X:
0.0, Y:0.0, Z:1.0 - Ray direction (up)
- X:
-
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)
-
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 Blockerscollection - 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:
- Value node (Input → Value)
- Value:
1.0 - Use this as Occlusion Factor (always exposed)
- Value:
- Focus on getting normal-based snow working first
- Debug raycast separately with simpler geometry
3. Combine Factors
Goal: Multiply normal factor × occlusion factor to get final snow mask
Add Nodes:
-
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)
-
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:
- 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):
-
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
-
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
-
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
-
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:
- Remove Set Position and Vector Math nodes entirely
- Just connect: Store Named Attribute → Group Output
- Check
snow_depthvalues in Spreadsheet Editor instead - Add a separate Geometry Nodes modifier for visualization if needed
- This is the cleanest approach for reliable baking
Viewport Verification:
- Open Spreadsheet Editor (Blender 5.0)
- Select terrain object
- Look for
snow_depthcolumn - 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:
- Open your terrain
.blendfile in Blender - Open Scripting workspace
- Text → Open → Navigate to
blender/scripts/generate_snow_depth.py - (Optional) If you have multiple Geometry Nodes modifiers (e.g., "Trees" and "Snow"):
- Edit the script's
__main__section - Change
modifier_name = Nonetomodifier_name = "Snow"(or your exact modifier name)
- Edit the script's
- 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_depthattribute (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_depthattribute 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":
- Check Spreadsheet Editor - does
snow_depthattribute column exist? - Verify Store Named Attribute node is connected in your Geometry Nodes tree
- Check modifier is enabled (eye icon in modifier stack)
- Try specifying the exact modifier name in the script
- 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:
- Select terrain mesh
- Enter Edit Mode (Tab)
- Select all vertices (A)
- UV → Unwrap or use Smart UV Project
- For simple terrain planes: Unwrap works well
- For complex terrain: Smart UV Project with low Island Margin
- In UV Editor, verify UVs fill the 0-1 space without major distortion
- 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):
- Open Shader Editor (if not already open)
- Add → Texture → Image Texture node
- 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
- Name:
- 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:
- 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
- With terrain mesh selected, open Render Properties panel (camera icon)
- Scroll to Bake section
- 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)
- Verify Image Texture node is still selected (white outline)
- 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_depthimage - 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:
-
Switch to UV Editor or Image Editor (any editor showing the image)
-
Ensure
snow_depthimage is active -
Image Menu → Save As (or Alt+S)
-
File Browser Settings:
- Navigate to your project:
meshes/directory - Filename:
snow_depth.exr - File Format: OpenEXR (should auto-select based on .exr extension)
- Navigate to your project:
-
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)
-
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:
- File → External Data → Edit Externally (opens in system viewer if available)
- 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:
-
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
-
Check Attribute Values:
- Open Spreadsheet Editor (new in Blender 5.0 - top menu bar)
- With terrain selected, spreadsheet shows geometry data
- Look for
snow_depthcolumn 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
-
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
-
Check Baked Texture:
- In Image Editor, view
snow_depthimage - View → Display Settings → Exposure: Increase if image looks too dark
- White pixels = snow coverage
- Black pixels = no snow
- Gray = partial coverage
- In Image Editor, view
Quick Test in Blender (Before Game Integration):
Method 1: UV Editor Visualization
- Split viewport → UV Editor
- Select terrain mesh
- In UV Editor, select
snow_depthimage from dropdown - UVs should align with snow coverage
Method 2: Re-import and Verify
- Create a test plane
- Add Image Texture node in Shader Editor
- Load
meshes/snow_depth.exr - Connect to Emission → Material Output
- View in Rendered mode - should see snow pattern
In Game (After Rust Implementation):
- Place
meshes/snow_depth.exrin project directory - Verify file loads without errors:
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)?; - Snow mesh should appear as white layer on terrain
- Test deformation with player movement
- 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:
- Saves all original materials
- Temporarily replaces all material slots with a single bake material
- Performs the bake
- 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_depthattribute 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:
- Select terrain object
- Check vertex count in Spreadsheet Editor
- If vertex count is exactly 2× your base mesh count → you're joining geometry
- Check your Geometry Nodes for Join Geometry or mesh duplication nodes
Solution:
Option 1: Remove Visual Offset (Simplest - Recommended)
- Open your "Snow Accumulation" Geometry Nodes tree
- 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)
- Final node chain should be:
[Calculate Snow Depth] ↓ [Store Named Attribute: "snow_depth"] ↓ [Group Output] (no offset, no join) - Re-run the baking script
- 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
- Create TWO Geometry Nodes modifiers on terrain:
- Modifier 1: "Snow Data" - Just calculates and stores
snow_depthattribute - Modifier 2: "Snow Visualization" - Reads attribute and creates visual offset
- Modifier 1: "Snow Data" - Just calculates and stores
- Disable Modifier 2 before baking
- Bake with only Modifier 1 enabled
- Re-enable Modifier 2 after export
Verification After Fix:
- Select terrain in Blender
- Open Spreadsheet Editor
- Check vertex count - should match your base terrain (not doubled)
- Check
snow_depthcolumn - should have non-zero values - Re-run
generate_snow_depth.pyscript - 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:
-
UV Layout Issue:
- UVs are overlapping or outside 0-1 range
- Solution: Re-unwrap terrain UVs, ensure no overlap
-
Modifier Stack Order:
- Other modifiers before snow are transforming geometry
- Solution: Move snow modifier to top of stack for testing
-
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:
- Add Viewer node after your Join Geometry
- Check Spreadsheet Editor - does it show geometry?
- 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:
- The Group Input geometry already includes realized trees from previous modifier
- DON'T realize Group Input again (causes issues)
- 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:
- In modifier properties, add Input → Geometry
- Name it:
Blocker_Geometry - Drag your tree-spawning object into this input
- 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:
- Skip all raycast nodes
- Add Vertex Color input node (Input → Color Attribute)
- Name:
occlusion_mask
- Name:
- Use as Occlusion Factor (white = exposed, black = blocked)
- In Edit Mode, use Vertex Paint mode to paint occlusion manually
- More control, but less automatic
Alternative Approach: Use Z-Depth
For simple overhang detection without raycast:
- Separate XYZ node
- Connect: Position → Vector
- Use Z output
- Compare node
- A: Z position
- B: Threshold value (height above which = no overhang)
- Greater Than
- Use as simple occlusion approximation
- Works for simple overhangs, not complex geometry
Iteration Workflow
To adjust snow coverage:
- Modify normal threshold (Map Range "From Min" value - lower = more snow on slopes)
- Adjust base snow depth multiplier (final Math → Multiply value)
- Re-bake attribute to texture (Render → Bake)
- Re-export EXR (Image → Save As)
- Restart game to reload (hot-reload not implemented yet)
For occluding objects:
- Add/remove objects from
Snow Blockerscollection - 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
- Put actual tree mesh in
Snow Blockerscollection - Use Collection Info → Realize Instances → Join Geometry
- 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:
- 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
- Example:
- Vector Math node: Normalize
- Input: Combine XYZ (0.3, 0, 1)
- Output → Ray Direction
- Creates asymmetric accumulation (more snow on lee side)
- 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
-
Create Geometry Nodes modifier on terrain
-
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] -
Test: Switch to Spreadsheet Editor, verify
snow_depthcolumn exists -
Bake and export following steps above
-
Verify in game - snow should appear on flat surfaces
Phase 2: Add Occlusion Later
Once Phase 1 works:
- Add Collection Info → Realize Instances → Join Geometry
- Insert Raycast between Map Range and final Multiply
- Multiply: normal_factor × occlusion_factor × 0.3
- Debug: Use Viewer node to verify blocker geometry exists
- Re-bake and test
Debugging Raycast Step-by-Step
Test 1: Verify Blocker Geometry Exists
- Add Viewer node after Realize Instances
- Select terrain object
- Open Spreadsheet Editor
- Check "Viewer" data source
- 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
- 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)
- Name:
- Bake this attribute to see occlusion map
- White = blocked, Black = exposed
- If all black/white uniformly → raycast isn't working
Test 3: Simple Cube Test
- Add a cube above your terrain
- Add cube to
Snow Blockerscollection - Position cube to cast obvious shadow
- Re-calculate geometry nodes
- 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_depthattribute 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.pyin Blender - Verify console output shows successful bake
- Check
textures/snow_depth.exrexists
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.exrrelative to project root - Ready for game integration
Game Integration:
- Update
SnowConfigpath 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
- Realize Instances is REQUIRED - Collection Info outputs instances by default
- Modifier stack order matters - Tree scatter must be BEFORE snow
- 32-bit Float must be set manually - Blender defaults to 16-bit Half for EXR
- Store Named Attribute replaces Capture Attribute - Old tutorials won't work
- Object Info doesn't take collections - Use Collection Info instead
- Instances are invisible to raycast - Must realize first
- Group Input includes previous modifiers - Can be used for tree instances