Compare commits
6 Commits
main
...
proc-terra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24053f2d51 | ||
| fe11763496 | |||
| 571605de8a | |||
| 7e502d30c1 | |||
| 54ef79eb82 | |||
| 8ed46a2ab9 |
461
.agents/skills/godot-gdscript/SKILL.md
Normal file
461
.agents/skills/godot-gdscript/SKILL.md
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
---
|
||||||
|
name: godot-gdscript
|
||||||
|
description: "Develops GDScript code for Godot 4.6 projects. Use when writing, editing, or debugging GDScript files, creating nodes, signals, exports, or any Godot game development task."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Godot 4.6 GDScript Development
|
||||||
|
|
||||||
|
Write idiomatic GDScript for Godot 4.6 following these conventions and language features.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
@tool # Optional: runs in editor
|
||||||
|
@icon("res://path/to/icon.svg") # Optional: custom icon
|
||||||
|
class_name MyClass # Optional: registers global class
|
||||||
|
extends BaseClass # Required (defaults to RefCounted)
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
signal health_changed(old_value, new_value)
|
||||||
|
|
||||||
|
## Enums
|
||||||
|
enum State { IDLE, RUNNING, JUMPING }
|
||||||
|
|
||||||
|
## Constants
|
||||||
|
const MAX_SPEED = 100.0
|
||||||
|
|
||||||
|
## Exports (inspector properties)
|
||||||
|
@export var speed: float = 50.0
|
||||||
|
@export_range(0, 100) var health: int = 100
|
||||||
|
|
||||||
|
## Public variables
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
## Private variables (convention: prefix with _)
|
||||||
|
var _internal_state: int = 0
|
||||||
|
|
||||||
|
## @onready variables (initialized in _ready)
|
||||||
|
@onready var sprite: Sprite2D = $Sprite2D
|
||||||
|
|
||||||
|
## Static variables
|
||||||
|
static var instance_count: int = 0
|
||||||
|
|
||||||
|
## Built-in virtual methods
|
||||||
|
func _init():
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _ready():
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _process(delta: float) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
## Public methods
|
||||||
|
func take_damage(amount: int) -> void:
|
||||||
|
pass
|
||||||
|
|
||||||
|
## Private methods
|
||||||
|
func _calculate_something() -> float:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
## Static methods
|
||||||
|
static func get_instance_count() -> int:
|
||||||
|
return instance_count
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type System
|
||||||
|
|
||||||
|
### Type Hints
|
||||||
|
```gdscript
|
||||||
|
var my_int: int = 5
|
||||||
|
var my_string: String = "hello"
|
||||||
|
var my_node: Node2D = null
|
||||||
|
var inferred := Vector2(1, 2) # Type inferred as Vector2
|
||||||
|
|
||||||
|
func my_func(param: int, optional: String = "default") -> bool:
|
||||||
|
return true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typed Arrays and Dictionaries
|
||||||
|
```gdscript
|
||||||
|
var int_array: Array[int] = [1, 2, 3]
|
||||||
|
var node_array: Array[Node] = []
|
||||||
|
var typed_dict: Dictionary[String, int] = {"a": 1, "b": 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Casting
|
||||||
|
```gdscript
|
||||||
|
var node := $Sprite as Sprite2D # Returns null if wrong type
|
||||||
|
if node is Sprite2D:
|
||||||
|
node.texture = some_texture
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Signals
|
||||||
|
```gdscript
|
||||||
|
signal damage_taken(amount: int)
|
||||||
|
|
||||||
|
func take_damage(amount: int) -> void:
|
||||||
|
health -= amount
|
||||||
|
damage_taken.emit(amount)
|
||||||
|
|
||||||
|
# Connecting signals
|
||||||
|
func _ready():
|
||||||
|
$Button.pressed.connect(_on_button_pressed)
|
||||||
|
# With bound arguments
|
||||||
|
$Enemy.died.connect(_on_enemy_died.bind(enemy_name))
|
||||||
|
|
||||||
|
func _on_button_pressed():
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Properties (Setters/Getters)
|
||||||
|
```gdscript
|
||||||
|
var health: int = 100:
|
||||||
|
get:
|
||||||
|
return health
|
||||||
|
set(value):
|
||||||
|
health = clamp(value, 0, max_health)
|
||||||
|
health_changed.emit(health)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export Annotations
|
||||||
|
```gdscript
|
||||||
|
@export var speed: float = 10.0
|
||||||
|
@export_range(0, 100, 1) var health: int = 100
|
||||||
|
@export_enum("Walk", "Run", "Jump") var movement: int
|
||||||
|
@export_file("*.png") var texture_path: String
|
||||||
|
@export_dir var save_directory: String
|
||||||
|
@export_node_path("Sprite2D", "Sprite3D") var sprite_path: NodePath
|
||||||
|
@export_flags("Fire", "Water", "Earth", "Wind") var elements: int
|
||||||
|
@export_group("Movement")
|
||||||
|
@export var walk_speed: float
|
||||||
|
@export var run_speed: float
|
||||||
|
@export_subgroup("Physics")
|
||||||
|
@export var gravity: float
|
||||||
|
```
|
||||||
|
|
||||||
|
### Node References
|
||||||
|
```gdscript
|
||||||
|
@onready var player := $Player as CharacterBody2D
|
||||||
|
@onready var unique_node := %UniqueNodeName # Scene-unique node
|
||||||
|
|
||||||
|
# Get nodes dynamically
|
||||||
|
var child = get_node("Path/To/Child")
|
||||||
|
var parent = get_parent()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Awaiting Signals/Coroutines
|
||||||
|
```gdscript
|
||||||
|
func async_operation() -> void:
|
||||||
|
await get_tree().create_timer(1.0).timeout
|
||||||
|
print("1 second passed")
|
||||||
|
|
||||||
|
var result = await some_async_func()
|
||||||
|
print(result)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Match Statements
|
||||||
|
```gdscript
|
||||||
|
match state:
|
||||||
|
State.IDLE:
|
||||||
|
play_idle_animation()
|
||||||
|
State.RUNNING:
|
||||||
|
play_run_animation()
|
||||||
|
_:
|
||||||
|
push_warning("Unknown state")
|
||||||
|
|
||||||
|
# Pattern matching with guards
|
||||||
|
match point:
|
||||||
|
[var x, var y] when x == y:
|
||||||
|
print("On diagonal")
|
||||||
|
[var x, var y]:
|
||||||
|
print("Point: ", x, ", ", y)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lambda Functions
|
||||||
|
```gdscript
|
||||||
|
var double = func(x): return x * 2
|
||||||
|
print(double.call(5)) # 10
|
||||||
|
|
||||||
|
# With type hints
|
||||||
|
var add: Callable = func(a: int, b: int) -> int: return a + b
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variadic Functions (4.5+)
|
||||||
|
```gdscript
|
||||||
|
func log_values(prefix: String, ...values: Array) -> void:
|
||||||
|
for v in values:
|
||||||
|
print(prefix, v)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Built-in Types Reference
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `null` | Empty value |
|
||||||
|
| `bool` | `true` or `false` |
|
||||||
|
| `int` | 64-bit integer |
|
||||||
|
| `float` | 64-bit float |
|
||||||
|
| `String` | Unicode string |
|
||||||
|
| `StringName` | Interned string (fast comparison) |
|
||||||
|
| `NodePath` | Path to node/property |
|
||||||
|
| `Vector2/Vector2i` | 2D vector |
|
||||||
|
| `Vector3/Vector3i` | 3D vector |
|
||||||
|
| `Rect2` | 2D rectangle |
|
||||||
|
| `Transform2D/Transform3D` | Transformation matrix |
|
||||||
|
| `Color` | RGBA color |
|
||||||
|
| `Array` | Dynamic array |
|
||||||
|
| `Dictionary` | Key-value map |
|
||||||
|
| `Callable` | Function reference |
|
||||||
|
| `Signal` | Signal reference |
|
||||||
|
|
||||||
|
## Memory Management
|
||||||
|
|
||||||
|
- `RefCounted` subclasses: Automatically freed when no references
|
||||||
|
- `Node` subclasses: Must call `queue_free()` or `free()`
|
||||||
|
- Use `weakref()` to avoid reference cycles
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var weak = weakref(some_object)
|
||||||
|
if weak.get_ref():
|
||||||
|
weak.get_ref().do_something()
|
||||||
|
|
||||||
|
if is_instance_valid(node):
|
||||||
|
node.queue_free()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Abstract Classes (4.5+)
|
||||||
|
```gdscript
|
||||||
|
@abstract
|
||||||
|
class_name BaseEnemy
|
||||||
|
extends CharacterBody2D
|
||||||
|
|
||||||
|
@abstract func attack() -> void
|
||||||
|
|
||||||
|
class Zombie extends BaseEnemy:
|
||||||
|
func attack() -> void:
|
||||||
|
# Must implement abstract method
|
||||||
|
melee_attack()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Style Guide
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
| Type | Convention | Example |
|
||||||
|
|------|------------|---------|
|
||||||
|
| File names | snake_case | `yaml_parser.gd` |
|
||||||
|
| Class names | PascalCase | `class_name YAMLParser` |
|
||||||
|
| Node names | PascalCase | `Camera3D`, `Player` |
|
||||||
|
| Functions | snake_case | `func load_level():` |
|
||||||
|
| Variables | snake_case | `var particle_effect` |
|
||||||
|
| Signals | snake_case (past tense) | `signal door_opened` |
|
||||||
|
| Constants | CONSTANT_CASE | `const MAX_SPEED = 200` |
|
||||||
|
| Enum names | PascalCase | `enum Element` |
|
||||||
|
| Enum members | CONSTANT_CASE | `{EARTH, WATER, AIR}` |
|
||||||
|
|
||||||
|
### Private Members
|
||||||
|
Prepend underscore for private/virtual methods and variables:
|
||||||
|
```gdscript
|
||||||
|
var _internal_state: int = 0
|
||||||
|
func _calculate_damage() -> int:
|
||||||
|
return 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Order
|
||||||
|
Organize scripts in this order:
|
||||||
|
1. `@tool` (if needed)
|
||||||
|
2. `@icon`
|
||||||
|
3. `class_name`
|
||||||
|
4. `extends`
|
||||||
|
5. `## Documentation comments`
|
||||||
|
6. Signals
|
||||||
|
7. Enums
|
||||||
|
8. Constants
|
||||||
|
9. `@export` variables
|
||||||
|
10. Public variables
|
||||||
|
11. Private variables
|
||||||
|
12. `@onready` variables
|
||||||
|
13. Virtual methods (`_init`, `_ready`, `_process`, etc.)
|
||||||
|
14. Public methods
|
||||||
|
15. Private methods
|
||||||
|
16. Inner classes
|
||||||
|
|
||||||
|
### Formatting Rules
|
||||||
|
|
||||||
|
**Indentation**: Use tabs, not spaces.
|
||||||
|
|
||||||
|
**Blank lines**:
|
||||||
|
- Two blank lines between functions/class definitions
|
||||||
|
- One blank line to separate logical sections within functions
|
||||||
|
|
||||||
|
**Line length**: Keep under 100 characters (prefer 80).
|
||||||
|
|
||||||
|
**Comments**:
|
||||||
|
```gdscript
|
||||||
|
# Regular comment (space after #)
|
||||||
|
## Documentation comment
|
||||||
|
#var disabled_code # No space for commented code
|
||||||
|
#region Region Name
|
||||||
|
#endregion
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multiline statements** - Use parentheses, put `and`/`or` at line start:
|
||||||
|
```gdscript
|
||||||
|
if (
|
||||||
|
is_valid
|
||||||
|
and has_permission
|
||||||
|
and not is_disabled
|
||||||
|
):
|
||||||
|
do_something()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enums** - One item per line:
|
||||||
|
```gdscript
|
||||||
|
enum State {
|
||||||
|
IDLE,
|
||||||
|
RUNNING,
|
||||||
|
JUMPING,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Static Typing
|
||||||
|
|
||||||
|
Static typing catches errors at compile time, improves autocompletion, and enables performance optimizations.
|
||||||
|
|
||||||
|
### Basic Syntax
|
||||||
|
```gdscript
|
||||||
|
# Variables
|
||||||
|
var health: int = 100
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
var player: CharacterBody2D
|
||||||
|
|
||||||
|
# Type inference with :=
|
||||||
|
var speed := 10.0 # Inferred as float
|
||||||
|
var position := Vector2.ZERO # Inferred as Vector2
|
||||||
|
|
||||||
|
# Constants (type optional but recommended for arrays)
|
||||||
|
const MAX_SPEED: float = 200.0
|
||||||
|
const ITEMS: Array[String] = ["sword", "shield"]
|
||||||
|
|
||||||
|
# Function parameters and return types
|
||||||
|
func calculate_damage(base: int, multiplier: float) -> int:
|
||||||
|
return int(base * multiplier)
|
||||||
|
|
||||||
|
# Void return type
|
||||||
|
func apply_damage(amount: int) -> void:
|
||||||
|
health -= amount
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typed Collections
|
||||||
|
```gdscript
|
||||||
|
# Typed arrays
|
||||||
|
var enemies: Array[Enemy] = []
|
||||||
|
var scores: Array[int] = [10, 20, 30]
|
||||||
|
|
||||||
|
# Typed dictionaries (Godot 4.4+)
|
||||||
|
var inventory: Dictionary[String, int] = {"gold": 100}
|
||||||
|
var stats: Dictionary[StringName, float] = {}
|
||||||
|
|
||||||
|
# Typed for loops
|
||||||
|
for enemy: Enemy in enemies:
|
||||||
|
enemy.take_damage(10)
|
||||||
|
|
||||||
|
for name: String in ["Alice", "Bob"]:
|
||||||
|
print(name)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Casting
|
||||||
|
```gdscript
|
||||||
|
# Safe casting with 'as' (returns null if wrong type)
|
||||||
|
var sprite := $Sprite as Sprite2D
|
||||||
|
if sprite:
|
||||||
|
sprite.texture = new_texture
|
||||||
|
|
||||||
|
# Type checking with 'is'
|
||||||
|
if node is CharacterBody2D:
|
||||||
|
var body: CharacterBody2D = node
|
||||||
|
body.move_and_slide()
|
||||||
|
|
||||||
|
# Casting node references for safe lines
|
||||||
|
@onready var timer := $Timer as Timer
|
||||||
|
@onready var player := $Player as CharacterBody2D
|
||||||
|
```
|
||||||
|
|
||||||
|
### Safe vs Unsafe Lines
|
||||||
|
The editor marks lines green (safe) or grey (unsafe):
|
||||||
|
```gdscript
|
||||||
|
# Safe - type is known
|
||||||
|
var timer: Timer = $Timer as Timer
|
||||||
|
|
||||||
|
# Unsafe - type could fail at runtime
|
||||||
|
var timer = $Timer # No type info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typed Global Scope Methods
|
||||||
|
Use typed versions for better performance:
|
||||||
|
```gdscript
|
||||||
|
# Prefer typed versions
|
||||||
|
var node: Node = instance_from_id(id) as Node # Safe
|
||||||
|
var clamped: int = clampi(value, 0, 100) # Typed int clamp
|
||||||
|
var lerped: float = lerpf(0.0, 1.0, 0.5) # Typed float lerp
|
||||||
|
|
||||||
|
# Typed math functions
|
||||||
|
var abs_val: int = absi(-5)
|
||||||
|
var max_val: float = maxf(a, b)
|
||||||
|
var min_val: int = mini(a, b)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Safe Property/Method Access
|
||||||
|
```gdscript
|
||||||
|
# Unsafe - warnings if property doesn't exist on type
|
||||||
|
func process_node(node: Node2D) -> void:
|
||||||
|
if "custom_property" in node:
|
||||||
|
node.custom_property = 10 # UNSAFE_PROPERTY_ACCESS warning
|
||||||
|
|
||||||
|
# Safe - cast first, then access
|
||||||
|
func process_node_safe(node: Node2D) -> void:
|
||||||
|
if node is MyScript:
|
||||||
|
var typed: MyScript = node as MyScript
|
||||||
|
typed.custom_property = 10 # Safe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
```gdscript
|
||||||
|
# Cannot type individual array/dict elements inline
|
||||||
|
var arr = [1, "two", 3.0] # Mixed types - can't specify
|
||||||
|
|
||||||
|
# Nested typed collections not supported
|
||||||
|
var nested: Array[Array[int]] # ERROR - not allowed
|
||||||
|
|
||||||
|
# Use this instead
|
||||||
|
var nested: Array[Array] = [[1, 2], [3, 4]]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Editor Settings
|
||||||
|
Enable in **Project Settings > Debug > GDScript**:
|
||||||
|
- `UNTYPED_DECLARATION` - Warn on untyped variables
|
||||||
|
- `INFERRED_DECLARATION` - Prefer explicit over inferred types
|
||||||
|
- `UNSAFE_PROPERTY_ACCESS` - Warn on unsafe property access
|
||||||
|
- `UNSAFE_METHOD_ACCESS` - Warn on unsafe method calls
|
||||||
|
|
||||||
|
Enable in **Editor Settings > Text Editor > Completion**:
|
||||||
|
- **Add Type Hints** - Auto-add types on completion
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Use static typing** - Improves performance and catches errors
|
||||||
|
2. **Prefer `@onready`** - For node references instead of `_ready()` assignments
|
||||||
|
3. **Use signals** - For decoupled communication between nodes
|
||||||
|
4. **Avoid `get_node()` in `_process()`** - Cache references with `@onready`
|
||||||
|
5. **Use `StringName` for frequent comparisons** - `&"my_string"`
|
||||||
|
6. **Group exports** - Use `@export_group` and `@export_subgroup`
|
||||||
|
7. **Document with `##`** - Double hash for documentation comments
|
||||||
|
8. **Declare locals near first use** - Don't declare at top of function
|
||||||
|
9. **Use past tense for signals** - `health_depleted`, `door_opened`
|
||||||
|
10. **Match file name to class** - `YAMLParser` → `yaml_parser.gd`
|
||||||
BIN
proc-terrain/Dirt_baseColor.tga
Normal file
BIN
proc-terrain/Dirt_baseColor.tga
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
BIN
proc-terrain/Grass_baseColor.tga
Normal file
BIN
proc-terrain/Grass_baseColor.tga
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 MiB |
100
proc-terrain/TERRAIN_CONTROL_SPEC.md
Normal file
100
proc-terrain/TERRAIN_CONTROL_SPEC.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Terrain Control System Specification
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Provide artists with deterministic, in-editor control over procedural terrain generation by
|
||||||
|
introducing control layers (biomes, height profiles, masks, slope limits) and hard scene
|
||||||
|
constraints (bel vedere stations and rail splines). The generator must combine these inputs
|
||||||
|
to produce a terrain mesh and mask/biome outputs suitable for materials and prop placement.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
- Allow artists to explicitly control biomes, height profiles, masks, and slope limits.
|
||||||
|
- Ensure rails and stations act as hard constraints that shape/flatten the terrain.
|
||||||
|
- Support a procedural rail spline (Path3D + Curve3D) with known points near stations.
|
||||||
|
- Provide real-time preview and regeneration in-editor.
|
||||||
|
- Expose data for props placement (density/exclusion by biome/slope/rail distance).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
- Final art-quality shader/material authoring.
|
||||||
|
- Runtime streaming or terrain LOD.
|
||||||
|
- Full track generation algorithm (assumed to exist elsewhere).
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
### Scene Inputs (Hard Constraints)
|
||||||
|
- Bel vedere stations: placed as scene nodes (Node3D).
|
||||||
|
- Rail spline: Path3D with Curve3D (procedural). Only points near stations are known.
|
||||||
|
|
||||||
|
### Artist Inputs (Control Layers)
|
||||||
|
- Biome map (Texture2D): defines biome IDs/weights across terrain.
|
||||||
|
- Height profile (Curve): maps base noise height to final height per biome.
|
||||||
|
- Mask layers (Texture2D array): painted/assigned regions (forest, village, rock, etc.).
|
||||||
|
- Slope limit map (Texture2D): limits maximum slope per region.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
### TerrainControlLayers (Resource)
|
||||||
|
- biome_map: Texture2D
|
||||||
|
- height_profile: Curve
|
||||||
|
- slope_limit_map: Texture2D
|
||||||
|
- mask_layers: Array[Texture2D]
|
||||||
|
- mask_names: Array[String]
|
||||||
|
- resolution: Vector2i (shared texture resolution for all layers)
|
||||||
|
|
||||||
|
### TerrainGenerator (Node)
|
||||||
|
- control_layers: TerrainControlLayers
|
||||||
|
- station_paths: Array[NodePath]
|
||||||
|
- rail_paths: Array[NodePath]
|
||||||
|
- rail_influence_radius: float
|
||||||
|
- station_influence_radius: float
|
||||||
|
- station_flatten_height: float
|
||||||
|
- rail_flatten_strength: float
|
||||||
|
- biome_preview_mode: enum (None, Biome, Slope, MaskIndex)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. Artist places bel vedere station nodes in the scene.
|
||||||
|
2. Rail spline is generated as Path3D with Curve3D (known points near stations).
|
||||||
|
3. Artist assigns/creates a TerrainControlLayers resource.
|
||||||
|
4. Artist paints biome map and mask layers in-editor and sets height/slope controls.
|
||||||
|
5. TerrainGenerator regenerates and previews terrain with rail/station constraints.
|
||||||
|
|
||||||
|
## Generation Pipeline
|
||||||
|
1. Base height: sample noise and scale by max_height.
|
||||||
|
2. Apply height profile: evaluate Curve to remap height.
|
||||||
|
3. Apply island/edge mask if enabled.
|
||||||
|
4. Sample biome map and masks to modulate height and material blending.
|
||||||
|
5. Apply slope limits by clamping local height gradients per pixel.
|
||||||
|
6. Apply rail constraints:
|
||||||
|
- Compute distance from vertex to nearest point on rail spline.
|
||||||
|
- Within rail_influence_radius, flatten height toward rail height.
|
||||||
|
- Enforce maximum slope along rail direction.
|
||||||
|
7. Apply station constraints:
|
||||||
|
- Within station_influence_radius, flatten to station height.
|
||||||
|
- Optionally expand a platform/yard mask for props and paths.
|
||||||
|
8. Output: mesh + per-vertex data for materials (biome/mask indices) and props.
|
||||||
|
|
||||||
|
## Rail Spline Handling
|
||||||
|
- Rail spline is Path3D with Curve3D and may be partially defined near stations.
|
||||||
|
- The generator samples the spline at fixed intervals to build a distance field.
|
||||||
|
- If only station-adjacent points exist, interpolate curve points between stations.
|
||||||
|
|
||||||
|
## Editor Tooling
|
||||||
|
- Terrain Control Painter:
|
||||||
|
- Paint biome IDs, slope limits, and masks directly on terrain.
|
||||||
|
- Toggle view modes (biome/slope/mask overlays).
|
||||||
|
- Provide brush size, hardness, and opacity.
|
||||||
|
- Preview toggle:
|
||||||
|
- Regenerate terrain on parameter changes in the editor.
|
||||||
|
|
||||||
|
## Props Integration
|
||||||
|
- Provide maps to props system:
|
||||||
|
- biome_id, slope_limit, rail_distance, station_distance, mask values.
|
||||||
|
- Placement rules can use these to control density and exclusions.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- Artists can paint biomes/masks and see results in-editor.
|
||||||
|
- Terrain respects rail and station constraints.
|
||||||
|
- Slope limits and height profiles affect terrain as expected.
|
||||||
|
- Outputs for props placement are available and consistent.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
- How is rail height defined relative to terrain (absolute Y or curve metadata)?
|
||||||
|
- Should station flattening use station Y or a per-station override value?
|
||||||
|
- Expected texture resolution for control layers?
|
||||||
BIN
proc-terrain/biomes.png
Normal file
BIN
proc-terrain/biomes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
4
proc-terrain/default_noise.tres
Normal file
4
proc-terrain/default_noise.tres
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[gd_resource type="FastNoiseLite" format=3 uid="uid://clpxlmv87brc7"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
seed = 42
|
||||||
16
proc-terrain/default_terrain_material.tres
Normal file
16
proc-terrain/default_terrain_material.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="ShaderMaterial" format=3 uid="uid://4a05tpi1tu14"]
|
||||||
|
|
||||||
|
[ext_resource type="Shader" uid="uid://d4b4lfd2r51iu" path="res://proc-terrain/triplanar_slope.gdshader" id="1_shader"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://cfffdsmmgils3" path="res://proc-terrain/Dirt_baseColor.tga" id="2_50hjp"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://dq7id4wwphvm6" path="res://proc-terrain/Grass_baseColor.tga" id="3_8s6t3"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
render_priority = 0
|
||||||
|
shader = ExtResource("1_shader")
|
||||||
|
shader_parameter/texture_top = ExtResource("3_8s6t3")
|
||||||
|
shader_parameter/texture_side = ExtResource("2_50hjp")
|
||||||
|
shader_parameter/blend_sharpness = 5.0
|
||||||
|
shader_parameter/slope_threshold = 0.7
|
||||||
|
shader_parameter/uv_scale = Vector3(0.1, 0.1, 0.1)
|
||||||
|
shader_parameter/texture_top_tiling = Vector2(2, 2)
|
||||||
|
shader_parameter/texture_side_tiling = Vector2(1, 1)
|
||||||
4
proc-terrain/preview_material.tres
Normal file
4
proc-terrain/preview_material.tres
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
[gd_resource type="StandardMaterial3D" format=3 uid="uid://40g5lvx2hw5k"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
vertex_color_use_as_albedo = true
|
||||||
BIN
proc-terrain/slop_limit_map.png
Normal file
BIN
proc-terrain/slop_limit_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
proc-terrain/slop_limit_map_1.png
Normal file
BIN
proc-terrain/slop_limit_map_1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
16
proc-terrain/terrain_control_layer.tres
Normal file
16
proc-terrain/terrain_control_layer.tres
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[gd_resource type="Resource" script_class="TerrainControlLayers" format=3 uid="uid://diftpul4fiqwa"]
|
||||||
|
|
||||||
|
[ext_resource type="Texture2D" uid="uid://cdx6jsbu8586x" path="res://proc-terrain/biomes.png" id="1_1o45a"]
|
||||||
|
[ext_resource type="Script" uid="uid://dn0dmf6vj6ybp" path="res://proc-terrain/terrain_control_layers.gd" id="1_2pv2t"]
|
||||||
|
[ext_resource type="Texture2D" uid="uid://b8dd3au482ax" path="res://proc-terrain/slop_limit_map.png" id="3_3iw73"]
|
||||||
|
|
||||||
|
[sub_resource type="Curve" id="Curve_2pv2t"]
|
||||||
|
_data = [Vector2(0, 0), 0.0, 0.0, 0, 0, Vector2(0.48395061, 1), 0.0, 0.0, 0, 0, Vector2(0.99814117, 0.004347801), 0.0, 0.0, 0, 0]
|
||||||
|
point_count = 3
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1_2pv2t")
|
||||||
|
resolution = Vector2i(1024, 1024)
|
||||||
|
biome_map = ExtResource("1_1o45a")
|
||||||
|
height_profile = SubResource("Curve_2pv2t")
|
||||||
|
slope_limit_map = ExtResource("3_3iw73")
|
||||||
110
proc-terrain/terrain_control_layers.gd
Normal file
110
proc-terrain/terrain_control_layers.gd
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
@tool
|
||||||
|
class_name TerrainControlLayers
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
## Resource containing artist-controlled terrain layers for procedural generation.
|
||||||
|
## Includes biome maps, height profiles, masks, and slope limits.
|
||||||
|
|
||||||
|
signal layers_changed
|
||||||
|
|
||||||
|
@export var resolution: Vector2i = Vector2i(512, 512):
|
||||||
|
set(value):
|
||||||
|
resolution = value
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
@export_group("Biome Control")
|
||||||
|
@export var biome_map: Texture2D:
|
||||||
|
set(value):
|
||||||
|
biome_map = value
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
@export var height_profile: Curve:
|
||||||
|
set(value):
|
||||||
|
if height_profile and height_profile.changed.is_connected(_on_curve_changed):
|
||||||
|
height_profile.changed.disconnect(_on_curve_changed)
|
||||||
|
height_profile = value
|
||||||
|
if height_profile:
|
||||||
|
height_profile.changed.connect(_on_curve_changed)
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
@export_group("Slope Control")
|
||||||
|
@export var slope_limit_map: Texture2D:
|
||||||
|
set(value):
|
||||||
|
slope_limit_map = value
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
@export_range(0.0, 90.0) var default_slope_limit: float = 45.0:
|
||||||
|
set(value):
|
||||||
|
default_slope_limit = value
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
@export_group("Mask Layers")
|
||||||
|
@export var mask_layers: Array[Texture2D] = []:
|
||||||
|
set(value):
|
||||||
|
mask_layers = value
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
@export var mask_names: Array[String] = []:
|
||||||
|
set(value):
|
||||||
|
mask_names = value
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_curve_changed() -> void:
|
||||||
|
emit_changed()
|
||||||
|
layers_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func get_biome_value(uv: Vector2) -> Color:
|
||||||
|
if not biome_map:
|
||||||
|
return Color.WHITE
|
||||||
|
var img := biome_map.get_image()
|
||||||
|
if not img:
|
||||||
|
return Color.WHITE
|
||||||
|
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
|
||||||
|
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
|
||||||
|
return img.get_pixel(px, py)
|
||||||
|
|
||||||
|
|
||||||
|
func get_slope_limit(uv: Vector2) -> float:
|
||||||
|
if not slope_limit_map:
|
||||||
|
return default_slope_limit
|
||||||
|
var img := slope_limit_map.get_image()
|
||||||
|
if not img:
|
||||||
|
return default_slope_limit
|
||||||
|
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
|
||||||
|
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
|
||||||
|
return img.get_pixel(px, py).r * 90.0
|
||||||
|
|
||||||
|
|
||||||
|
func get_height_profile_value(normalized_height: float) -> float:
|
||||||
|
if not height_profile:
|
||||||
|
return normalized_height
|
||||||
|
return height_profile.sample(clampf(normalized_height, 0.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
func get_mask_value(mask_index: int, uv: Vector2) -> float:
|
||||||
|
if mask_index < 0 or mask_index >= mask_layers.size():
|
||||||
|
return 0.0
|
||||||
|
var tex := mask_layers[mask_index]
|
||||||
|
if not tex:
|
||||||
|
return 0.0
|
||||||
|
var img := tex.get_image()
|
||||||
|
if not img:
|
||||||
|
return 0.0
|
||||||
|
var px := clampi(int(uv.x * img.get_width()), 0, img.get_width() - 1)
|
||||||
|
var py := clampi(int(uv.y * img.get_height()), 0, img.get_height() - 1)
|
||||||
|
return img.get_pixel(px, py).r
|
||||||
|
|
||||||
|
|
||||||
|
func get_mask_name(mask_index: int) -> String:
|
||||||
|
if mask_index < 0 or mask_index >= mask_names.size():
|
||||||
|
return "mask_%d" % mask_index
|
||||||
|
return mask_names[mask_index]
|
||||||
1
proc-terrain/terrain_control_layers.gd.uid
Normal file
1
proc-terrain/terrain_control_layers.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dn0dmf6vj6ybp
|
||||||
507
proc-terrain/terrain_generator.gd
Normal file
507
proc-terrain/terrain_generator.gd
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
@tool
|
||||||
|
class_name TerrainGenerator
|
||||||
|
extends MeshInstance3D
|
||||||
|
|
||||||
|
enum PreviewMode { NONE, BIOME, SLOPE, MASK_INDEX, RAIL_DISTANCE, STATION_DISTANCE }
|
||||||
|
|
||||||
|
signal terrain_generated
|
||||||
|
signal props_data_ready(props_data: TerrainPropsData)
|
||||||
|
|
||||||
|
@export var noise_setting: FastNoiseLite:
|
||||||
|
set(value):
|
||||||
|
if noise_setting and noise_setting.changed.is_connected(_on_settings_changed):
|
||||||
|
noise_setting.changed.disconnect(_on_settings_changed)
|
||||||
|
noise_setting = value
|
||||||
|
if noise_setting:
|
||||||
|
noise_setting.changed.connect(_on_settings_changed)
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var material_setting: Material:
|
||||||
|
set(value):
|
||||||
|
material_setting = value
|
||||||
|
if mesh:
|
||||||
|
mesh.surface_set_material(0, material_setting)
|
||||||
|
|
||||||
|
@export_group("Terrain Size")
|
||||||
|
@export var terrain_size: Vector2 = Vector2(200, 200):
|
||||||
|
set(value):
|
||||||
|
terrain_size = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_range(2, 512) var subdivision: int = 128:
|
||||||
|
set(value):
|
||||||
|
subdivision = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var max_height: float = 50.0:
|
||||||
|
set(value):
|
||||||
|
max_height = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_group("Island Mask")
|
||||||
|
@export var island_mask: bool = true:
|
||||||
|
set(value):
|
||||||
|
island_mask = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_range(0.0, 1.0) var mask_falloff_start: float = 0.5:
|
||||||
|
set(value):
|
||||||
|
mask_falloff_start = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_range(0.1, 2.0) var mask_power: float = 1.5:
|
||||||
|
set(value):
|
||||||
|
mask_power = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_group("Control Layers")
|
||||||
|
@export var control_layers: TerrainControlLayers:
|
||||||
|
set(value):
|
||||||
|
if control_layers and control_layers.layers_changed.is_connected(_on_settings_changed):
|
||||||
|
control_layers.layers_changed.disconnect(_on_settings_changed)
|
||||||
|
control_layers = value
|
||||||
|
if control_layers:
|
||||||
|
control_layers.layers_changed.connect(_on_settings_changed)
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_group("Rail Constraints")
|
||||||
|
@export var rail_paths: Array[NodePath] = []:
|
||||||
|
set(value):
|
||||||
|
rail_paths = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var rail_influence_radius: float = 10.0:
|
||||||
|
set(value):
|
||||||
|
rail_influence_radius = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var rail_flatten_strength: float = 1.0:
|
||||||
|
set(value):
|
||||||
|
rail_flatten_strength = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_range(0.0, 45.0) var rail_max_slope: float = 5.0:
|
||||||
|
set(value):
|
||||||
|
rail_max_slope = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_group("Station Constraints")
|
||||||
|
@export var station_paths: Array[NodePath] = []:
|
||||||
|
set(value):
|
||||||
|
station_paths = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var station_influence_radius: float = 25.0:
|
||||||
|
set(value):
|
||||||
|
station_influence_radius = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var station_flatten_height: float = 0.0:
|
||||||
|
set(value):
|
||||||
|
station_flatten_height = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var use_station_y_as_height: bool = true:
|
||||||
|
set(value):
|
||||||
|
use_station_y_as_height = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_group("Preview")
|
||||||
|
@export var preview_mode: PreviewMode = PreviewMode.NONE:
|
||||||
|
set(value):
|
||||||
|
preview_mode = value
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export var preview_mask_index: int = 0:
|
||||||
|
set(value):
|
||||||
|
preview_mask_index = value
|
||||||
|
if preview_mode == PreviewMode.MASK_INDEX:
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
@export_group("Actions")
|
||||||
|
@export var regenerate: bool = false:
|
||||||
|
set(_value):
|
||||||
|
generate_terrain()
|
||||||
|
|
||||||
|
@export var bake_collision: bool = false:
|
||||||
|
set(_value):
|
||||||
|
_bake_collision()
|
||||||
|
|
||||||
|
var _update_queued: bool = false
|
||||||
|
var _rail_spline_cache: Array[Curve3D] = []
|
||||||
|
var _station_positions: Array[Vector3] = []
|
||||||
|
var _props_data: TerrainPropsData
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
if noise_setting:
|
||||||
|
if not noise_setting.changed.is_connected(_on_settings_changed):
|
||||||
|
noise_setting.changed.connect(_on_settings_changed)
|
||||||
|
generate_terrain()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_settings_changed() -> void:
|
||||||
|
_request_update()
|
||||||
|
|
||||||
|
|
||||||
|
func _request_update() -> void:
|
||||||
|
if _update_queued:
|
||||||
|
return
|
||||||
|
_update_queued = true
|
||||||
|
call_deferred("_do_update")
|
||||||
|
|
||||||
|
|
||||||
|
func _do_update() -> void:
|
||||||
|
_update_queued = false
|
||||||
|
generate_terrain()
|
||||||
|
|
||||||
|
|
||||||
|
func generate_terrain() -> void:
|
||||||
|
if not noise_setting:
|
||||||
|
return
|
||||||
|
|
||||||
|
_cache_constraints()
|
||||||
|
|
||||||
|
var st := SurfaceTool.new()
|
||||||
|
st.begin(Mesh.PRIMITIVE_TRIANGLES)
|
||||||
|
|
||||||
|
var step_x: float = terrain_size.x / float(subdivision - 1)
|
||||||
|
var step_z: float = terrain_size.y / float(subdivision - 1)
|
||||||
|
var half_x: float = terrain_size.x / 2.0
|
||||||
|
var half_z: float = terrain_size.y / 2.0
|
||||||
|
|
||||||
|
var heights: Array[float] = []
|
||||||
|
var biome_ids: Array[int] = []
|
||||||
|
var slope_values: Array[float] = []
|
||||||
|
var rail_distances: Array[float] = []
|
||||||
|
var station_distances: Array[float] = []
|
||||||
|
var mask_values: Array[Array] = []
|
||||||
|
|
||||||
|
heights.resize(subdivision * subdivision)
|
||||||
|
biome_ids.resize(subdivision * subdivision)
|
||||||
|
slope_values.resize(subdivision * subdivision)
|
||||||
|
rail_distances.resize(subdivision * subdivision)
|
||||||
|
station_distances.resize(subdivision * subdivision)
|
||||||
|
|
||||||
|
var mask_count := 0
|
||||||
|
if control_layers:
|
||||||
|
mask_count = control_layers.mask_layers.size()
|
||||||
|
for i in range(mask_count):
|
||||||
|
var arr: Array[float] = []
|
||||||
|
arr.resize(subdivision * subdivision)
|
||||||
|
mask_values.append(arr)
|
||||||
|
|
||||||
|
# Pass 1: Generate base heights
|
||||||
|
for z in range(subdivision):
|
||||||
|
for x in range(subdivision):
|
||||||
|
var world_x: float = x * step_x - half_x
|
||||||
|
var world_z: float = z * step_z - half_z
|
||||||
|
var idx: int = z * subdivision + x
|
||||||
|
var uv := Vector2(float(x) / float(subdivision - 1), float(z) / float(subdivision - 1))
|
||||||
|
|
||||||
|
var base_noise: float = (noise_setting.get_noise_2d(world_x, world_z) + 1.0) * 0.5
|
||||||
|
var height: float = base_noise * max_height
|
||||||
|
|
||||||
|
# Apply height profile from control layers
|
||||||
|
if control_layers and control_layers.height_profile:
|
||||||
|
height = control_layers.get_height_profile_value(base_noise) * max_height
|
||||||
|
|
||||||
|
# Apply island mask
|
||||||
|
if island_mask:
|
||||||
|
height *= _calculate_island_mask(world_x, world_z, half_x, half_z)
|
||||||
|
|
||||||
|
# Sample biome
|
||||||
|
var biome_color := Color.WHITE
|
||||||
|
if control_layers:
|
||||||
|
biome_color = control_layers.get_biome_value(uv)
|
||||||
|
biome_ids[idx] = _color_to_biome_id(biome_color)
|
||||||
|
|
||||||
|
# Sample slope limit
|
||||||
|
var slope_limit := 45.0
|
||||||
|
if control_layers:
|
||||||
|
slope_limit = control_layers.get_slope_limit(uv)
|
||||||
|
slope_values[idx] = slope_limit
|
||||||
|
|
||||||
|
# Sample masks
|
||||||
|
for m in range(mask_count):
|
||||||
|
mask_values[m][idx] = control_layers.get_mask_value(m, uv)
|
||||||
|
|
||||||
|
heights[idx] = height
|
||||||
|
|
||||||
|
# Pass 2: Apply rail constraints
|
||||||
|
for z in range(subdivision):
|
||||||
|
for x in range(subdivision):
|
||||||
|
var world_x: float = x * step_x - half_x
|
||||||
|
var world_z: float = z * step_z - half_z
|
||||||
|
var idx: int = z * subdivision + x
|
||||||
|
var world_pos := Vector3(world_x, heights[idx], world_z)
|
||||||
|
|
||||||
|
var rail_result := _get_rail_constraint(world_pos)
|
||||||
|
rail_distances[idx] = rail_result.distance
|
||||||
|
|
||||||
|
if rail_result.distance < rail_influence_radius and rail_result.distance >= 0.0:
|
||||||
|
var t: float = 1.0 - (rail_result.distance / rail_influence_radius)
|
||||||
|
t = t * t * rail_flatten_strength
|
||||||
|
heights[idx] = lerpf(heights[idx], rail_result.height, t)
|
||||||
|
|
||||||
|
# Pass 3: Apply station constraints
|
||||||
|
for z in range(subdivision):
|
||||||
|
for x in range(subdivision):
|
||||||
|
var world_x: float = x * step_x - half_x
|
||||||
|
var world_z: float = z * step_z - half_z
|
||||||
|
var idx: int = z * subdivision + x
|
||||||
|
var world_pos := Vector3(world_x, heights[idx], world_z)
|
||||||
|
|
||||||
|
var station_result := _get_station_constraint(world_pos)
|
||||||
|
station_distances[idx] = station_result.distance
|
||||||
|
|
||||||
|
if station_result.distance < station_influence_radius and station_result.distance >= 0.0:
|
||||||
|
var t: float = 1.0 - (station_result.distance / station_influence_radius)
|
||||||
|
t = t * t
|
||||||
|
heights[idx] = lerpf(heights[idx], station_result.height, t)
|
||||||
|
|
||||||
|
# Pass 4: Apply slope limits
|
||||||
|
_apply_slope_limits(heights, slope_values, step_x, step_z)
|
||||||
|
|
||||||
|
# Build props data
|
||||||
|
_props_data = TerrainPropsData.new()
|
||||||
|
_props_data.resolution = Vector2i(subdivision, subdivision)
|
||||||
|
_props_data.terrain_size = terrain_size
|
||||||
|
_props_data.heights = heights
|
||||||
|
_props_data.biome_ids = biome_ids
|
||||||
|
_props_data.slope_values = slope_values
|
||||||
|
_props_data.rail_distances = rail_distances
|
||||||
|
_props_data.station_distances = station_distances
|
||||||
|
_props_data.mask_values = mask_values
|
||||||
|
|
||||||
|
# Build mesh
|
||||||
|
for z in range(subdivision - 1):
|
||||||
|
for x in range(subdivision - 1):
|
||||||
|
var idx00: int = z * subdivision + x
|
||||||
|
var idx10: int = z * subdivision + (x + 1)
|
||||||
|
var idx01: int = (z + 1) * subdivision + x
|
||||||
|
var idx11: int = (z + 1) * subdivision + (x + 1)
|
||||||
|
|
||||||
|
var x0: float = x * step_x - half_x
|
||||||
|
var x1: float = (x + 1) * step_x - half_x
|
||||||
|
var z0: float = z * step_z - half_z
|
||||||
|
var z1: float = (z + 1) * step_z - half_z
|
||||||
|
|
||||||
|
var v00 := Vector3(x0, heights[idx00], z0)
|
||||||
|
var v10 := Vector3(x1, heights[idx10], z0)
|
||||||
|
var v01 := Vector3(x0, heights[idx01], z1)
|
||||||
|
var v11 := Vector3(x1, heights[idx11], z1)
|
||||||
|
|
||||||
|
var uv00 := Vector2(x0 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5)
|
||||||
|
var uv10 := Vector2(x1 / terrain_size.x + 0.5, z0 / terrain_size.y + 0.5)
|
||||||
|
var uv01 := Vector2(x0 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5)
|
||||||
|
var uv11 := Vector2(x1 / terrain_size.x + 0.5, z1 / terrain_size.y + 0.5)
|
||||||
|
|
||||||
|
# Preview mode coloring
|
||||||
|
var c00 := _get_preview_color(idx00, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
||||||
|
var c10 := _get_preview_color(idx10, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
||||||
|
var c01 := _get_preview_color(idx01, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
||||||
|
var c11 := _get_preview_color(idx11, biome_ids, slope_values, rail_distances, station_distances, mask_values)
|
||||||
|
|
||||||
|
# Triangle 1: v00, v10, v01 (CCW winding, normal pointing up)
|
||||||
|
var normal1: Vector3 = (v01 - v00).cross(v10 - v00).normalized()
|
||||||
|
st.set_normal(normal1)
|
||||||
|
st.set_color(c00)
|
||||||
|
st.set_uv(uv00)
|
||||||
|
st.add_vertex(v00)
|
||||||
|
st.set_color(c10)
|
||||||
|
st.set_uv(uv10)
|
||||||
|
st.add_vertex(v10)
|
||||||
|
st.set_color(c01)
|
||||||
|
st.set_uv(uv01)
|
||||||
|
st.add_vertex(v01)
|
||||||
|
|
||||||
|
# Triangle 2: v10, v11, v01 (CCW winding, normal pointing up)
|
||||||
|
var normal2: Vector3 = (v01 - v10).cross(v11 - v10).normalized()
|
||||||
|
st.set_normal(normal2)
|
||||||
|
st.set_color(c10)
|
||||||
|
st.set_uv(uv10)
|
||||||
|
st.add_vertex(v10)
|
||||||
|
st.set_color(c11)
|
||||||
|
st.set_uv(uv11)
|
||||||
|
st.add_vertex(v11)
|
||||||
|
st.set_color(c01)
|
||||||
|
st.set_uv(uv01)
|
||||||
|
st.add_vertex(v01)
|
||||||
|
|
||||||
|
mesh = st.commit()
|
||||||
|
|
||||||
|
if material_setting:
|
||||||
|
mesh.surface_set_material(0, material_setting)
|
||||||
|
|
||||||
|
terrain_generated.emit()
|
||||||
|
props_data_ready.emit(_props_data)
|
||||||
|
|
||||||
|
|
||||||
|
func _cache_constraints() -> void:
|
||||||
|
_rail_spline_cache.clear()
|
||||||
|
_station_positions.clear()
|
||||||
|
|
||||||
|
for path in rail_paths:
|
||||||
|
if path.is_empty():
|
||||||
|
continue
|
||||||
|
var node := get_node_or_null(path)
|
||||||
|
if node is Path3D:
|
||||||
|
var path3d := node as Path3D
|
||||||
|
if path3d.curve:
|
||||||
|
_rail_spline_cache.append(path3d.curve)
|
||||||
|
|
||||||
|
for path in station_paths:
|
||||||
|
if path.is_empty():
|
||||||
|
continue
|
||||||
|
var node := get_node_or_null(path)
|
||||||
|
if node is Node3D:
|
||||||
|
_station_positions.append((node as Node3D).global_position)
|
||||||
|
|
||||||
|
|
||||||
|
func _get_rail_constraint(world_pos: Vector3) -> Dictionary:
|
||||||
|
var min_dist := INF
|
||||||
|
var rail_height := world_pos.y
|
||||||
|
|
||||||
|
for curve in _rail_spline_cache:
|
||||||
|
var closest := curve.get_closest_point(world_pos)
|
||||||
|
var dist := Vector2(world_pos.x - closest.x, world_pos.z - closest.z).length()
|
||||||
|
if dist < min_dist:
|
||||||
|
min_dist = dist
|
||||||
|
rail_height = closest.y
|
||||||
|
|
||||||
|
return {"distance": min_dist, "height": rail_height}
|
||||||
|
|
||||||
|
|
||||||
|
func _get_station_constraint(world_pos: Vector3) -> Dictionary:
|
||||||
|
var min_dist := INF
|
||||||
|
var target_height := station_flatten_height
|
||||||
|
|
||||||
|
for station_pos in _station_positions:
|
||||||
|
var dist := Vector2(world_pos.x - station_pos.x, world_pos.z - station_pos.z).length()
|
||||||
|
if dist < min_dist:
|
||||||
|
min_dist = dist
|
||||||
|
if use_station_y_as_height:
|
||||||
|
target_height = station_pos.y
|
||||||
|
|
||||||
|
return {"distance": min_dist, "height": target_height}
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_slope_limits(heights: Array[float], slope_limits: Array[float], step_x: float, step_z: float) -> void:
|
||||||
|
var max_iterations := 10
|
||||||
|
for _iter in range(max_iterations):
|
||||||
|
var changed := false
|
||||||
|
for z in range(1, subdivision - 1):
|
||||||
|
for x in range(1, subdivision - 1):
|
||||||
|
var idx: int = z * subdivision + x
|
||||||
|
var slope_limit_rad: float = deg_to_rad(slope_limits[idx])
|
||||||
|
var max_delta_x: float = tan(slope_limit_rad) * step_x
|
||||||
|
var max_delta_z: float = tan(slope_limit_rad) * step_z
|
||||||
|
|
||||||
|
var h := heights[idx]
|
||||||
|
var h_xm := heights[idx - 1]
|
||||||
|
var h_xp := heights[idx + 1]
|
||||||
|
var h_zm := heights[idx - subdivision]
|
||||||
|
var h_zp := heights[idx + subdivision]
|
||||||
|
|
||||||
|
var target := h
|
||||||
|
if abs(h - h_xm) > max_delta_x:
|
||||||
|
target = minf(target, h_xm + max_delta_x) if h > h_xm else maxf(target, h_xm - max_delta_x)
|
||||||
|
changed = true
|
||||||
|
if abs(h - h_xp) > max_delta_x:
|
||||||
|
target = minf(target, h_xp + max_delta_x) if h > h_xp else maxf(target, h_xp - max_delta_x)
|
||||||
|
changed = true
|
||||||
|
if abs(h - h_zm) > max_delta_z:
|
||||||
|
target = minf(target, h_zm + max_delta_z) if h > h_zm else maxf(target, h_zm - max_delta_z)
|
||||||
|
changed = true
|
||||||
|
if abs(h - h_zp) > max_delta_z:
|
||||||
|
target = minf(target, h_zp + max_delta_z) if h > h_zp else maxf(target, h_zp - max_delta_z)
|
||||||
|
changed = true
|
||||||
|
|
||||||
|
heights[idx] = lerpf(h, target, 0.5)
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
func _color_to_biome_id(color: Color) -> int:
|
||||||
|
return int(color.r * 255.0) + int(color.g * 255.0) * 256
|
||||||
|
|
||||||
|
|
||||||
|
func _get_preview_color(idx: int, biome_ids: Array[int], slope_values: Array[float],
|
||||||
|
rail_distances: Array[float], station_distances: Array[float], mask_values: Array[Array]) -> Color:
|
||||||
|
match preview_mode:
|
||||||
|
PreviewMode.NONE:
|
||||||
|
return Color.WHITE
|
||||||
|
PreviewMode.BIOME:
|
||||||
|
var biome := biome_ids[idx]
|
||||||
|
return Color.from_hsv(fmod(float(biome) * 0.618033988749, 1.0), 0.7, 0.9)
|
||||||
|
PreviewMode.SLOPE:
|
||||||
|
var slope := slope_values[idx] / 90.0
|
||||||
|
return Color(slope, 1.0 - slope, 0.0)
|
||||||
|
PreviewMode.MASK_INDEX:
|
||||||
|
if preview_mask_index < mask_values.size():
|
||||||
|
var v: float = mask_values[preview_mask_index][idx]
|
||||||
|
return Color(v, v, v)
|
||||||
|
return Color.BLACK
|
||||||
|
PreviewMode.RAIL_DISTANCE:
|
||||||
|
var d: float = clampf(rail_distances[idx] / (rail_influence_radius * 2.0), 0.0, 1.0)
|
||||||
|
return Color(1.0 - d, 0.2, d)
|
||||||
|
PreviewMode.STATION_DISTANCE:
|
||||||
|
var d: float = clampf(station_distances[idx] / (station_influence_radius * 2.0), 0.0, 1.0)
|
||||||
|
return Color(0.2, 1.0 - d, d)
|
||||||
|
return Color.WHITE
|
||||||
|
|
||||||
|
|
||||||
|
func get_props_data() -> TerrainPropsData:
|
||||||
|
return _props_data
|
||||||
|
|
||||||
|
|
||||||
|
func _calculate_island_mask(x: float, z: float, half_x: float, half_z: float) -> float:
|
||||||
|
var nx: float = x / half_x
|
||||||
|
var nz: float = z / half_z
|
||||||
|
var dist: float = sqrt(nx * nx + nz * nz)
|
||||||
|
|
||||||
|
if dist < mask_falloff_start:
|
||||||
|
return 1.0
|
||||||
|
elif dist > 1.0:
|
||||||
|
return 0.0
|
||||||
|
else:
|
||||||
|
var t: float = (dist - mask_falloff_start) / (1.0 - mask_falloff_start)
|
||||||
|
return pow(1.0 - t, mask_power)
|
||||||
|
|
||||||
|
|
||||||
|
func _bake_collision() -> void:
|
||||||
|
if not mesh:
|
||||||
|
push_warning("TerrainGenerator: No mesh to bake collision from")
|
||||||
|
return
|
||||||
|
|
||||||
|
var static_body: StaticBody3D
|
||||||
|
var collision_shape: CollisionShape3D
|
||||||
|
|
||||||
|
for child in get_children():
|
||||||
|
if child is StaticBody3D:
|
||||||
|
static_body = child
|
||||||
|
break
|
||||||
|
|
||||||
|
if not static_body:
|
||||||
|
static_body = StaticBody3D.new()
|
||||||
|
static_body.name = "TerrainCollision"
|
||||||
|
add_child(static_body)
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
static_body.owner = get_tree().edited_scene_root
|
||||||
|
|
||||||
|
for child in static_body.get_children():
|
||||||
|
if child is CollisionShape3D:
|
||||||
|
collision_shape = child
|
||||||
|
break
|
||||||
|
|
||||||
|
if not collision_shape:
|
||||||
|
collision_shape = CollisionShape3D.new()
|
||||||
|
collision_shape.name = "CollisionShape"
|
||||||
|
static_body.add_child(collision_shape)
|
||||||
|
if Engine.is_editor_hint():
|
||||||
|
collision_shape.owner = get_tree().edited_scene_root
|
||||||
|
|
||||||
|
collision_shape.shape = mesh.create_trimesh_shape()
|
||||||
|
print("TerrainGenerator: Collision baked successfully")
|
||||||
1
proc-terrain/terrain_generator.gd.uid
Normal file
1
proc-terrain/terrain_generator.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://brr6het2uwnp5
|
||||||
153
proc-terrain/terrain_props_data.gd
Normal file
153
proc-terrain/terrain_props_data.gd
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
@tool
|
||||||
|
class_name TerrainPropsData
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
## Data structure containing terrain information for props placement.
|
||||||
|
## Generated by TerrainGenerator after terrain generation.
|
||||||
|
|
||||||
|
var resolution: Vector2i = Vector2i(128, 128)
|
||||||
|
var terrain_size: Vector2 = Vector2(200, 200)
|
||||||
|
var heights: Array[float] = []
|
||||||
|
var biome_ids: Array[int] = []
|
||||||
|
var slope_values: Array[float] = []
|
||||||
|
var rail_distances: Array[float] = []
|
||||||
|
var station_distances: Array[float] = []
|
||||||
|
var mask_values: Array[Array] = []
|
||||||
|
|
||||||
|
|
||||||
|
func get_index(x: int, z: int) -> int:
|
||||||
|
return z * resolution.x + x
|
||||||
|
|
||||||
|
|
||||||
|
func get_uv(x: int, z: int) -> Vector2:
|
||||||
|
return Vector2(float(x) / float(resolution.x - 1), float(z) / float(resolution.y - 1))
|
||||||
|
|
||||||
|
|
||||||
|
func get_world_position(x: int, z: int) -> Vector3:
|
||||||
|
var half_x := terrain_size.x / 2.0
|
||||||
|
var half_z := terrain_size.y / 2.0
|
||||||
|
var step_x := terrain_size.x / float(resolution.x - 1)
|
||||||
|
var step_z := terrain_size.y / float(resolution.y - 1)
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
return Vector3(
|
||||||
|
x * step_x - half_x,
|
||||||
|
heights[idx] if idx < heights.size() else 0.0,
|
||||||
|
z * step_z - half_z
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func get_height(x: int, z: int) -> float:
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
if idx < 0 or idx >= heights.size():
|
||||||
|
return 0.0
|
||||||
|
return heights[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func get_biome_id(x: int, z: int) -> int:
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
if idx < 0 or idx >= biome_ids.size():
|
||||||
|
return 0
|
||||||
|
return biome_ids[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func get_slope_limit(x: int, z: int) -> float:
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
if idx < 0 or idx >= slope_values.size():
|
||||||
|
return 45.0
|
||||||
|
return slope_values[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func get_rail_distance(x: int, z: int) -> float:
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
if idx < 0 or idx >= rail_distances.size():
|
||||||
|
return INF
|
||||||
|
return rail_distances[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func get_station_distance(x: int, z: int) -> float:
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
if idx < 0 or idx >= station_distances.size():
|
||||||
|
return INF
|
||||||
|
return station_distances[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func get_mask_value(mask_index: int, x: int, z: int) -> float:
|
||||||
|
if mask_index < 0 or mask_index >= mask_values.size():
|
||||||
|
return 0.0
|
||||||
|
var mask_arr: Array = mask_values[mask_index]
|
||||||
|
var idx := get_index(x, z)
|
||||||
|
if idx < 0 or idx >= mask_arr.size():
|
||||||
|
return 0.0
|
||||||
|
return mask_arr[idx]
|
||||||
|
|
||||||
|
|
||||||
|
func get_calculated_slope(x: int, z: int) -> float:
|
||||||
|
if x <= 0 or x >= resolution.x - 1 or z <= 0 or z >= resolution.y - 1:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
var step_x := terrain_size.x / float(resolution.x - 1)
|
||||||
|
var step_z := terrain_size.y / float(resolution.y - 1)
|
||||||
|
|
||||||
|
var h := get_height(x, z)
|
||||||
|
var h_xm := get_height(x - 1, z)
|
||||||
|
var h_xp := get_height(x + 1, z)
|
||||||
|
var h_zm := get_height(x, z - 1)
|
||||||
|
var h_zp := get_height(x, z + 1)
|
||||||
|
|
||||||
|
var dx := (h_xp - h_xm) / (2.0 * step_x)
|
||||||
|
var dz := (h_zp - h_zm) / (2.0 * step_z)
|
||||||
|
|
||||||
|
return rad_to_deg(atan(sqrt(dx * dx + dz * dz)))
|
||||||
|
|
||||||
|
|
||||||
|
func can_place_prop(x: int, z: int, rules: Dictionary) -> bool:
|
||||||
|
var slope := get_calculated_slope(x, z)
|
||||||
|
var rail_dist := get_rail_distance(x, z)
|
||||||
|
var station_dist := get_station_distance(x, z)
|
||||||
|
var biome := get_biome_id(x, z)
|
||||||
|
|
||||||
|
if rules.has("max_slope") and slope > rules["max_slope"]:
|
||||||
|
return false
|
||||||
|
if rules.has("min_rail_distance") and rail_dist < rules["min_rail_distance"]:
|
||||||
|
return false
|
||||||
|
if rules.has("min_station_distance") and station_dist < rules["min_station_distance"]:
|
||||||
|
return false
|
||||||
|
if rules.has("allowed_biomes") and not biome in rules["allowed_biomes"]:
|
||||||
|
return false
|
||||||
|
if rules.has("excluded_biomes") and biome in rules["excluded_biomes"]:
|
||||||
|
return false
|
||||||
|
|
||||||
|
if rules.has("required_mask"):
|
||||||
|
var mask_idx: int = rules["required_mask"]
|
||||||
|
var threshold: float = rules.get("mask_threshold", 0.5)
|
||||||
|
if get_mask_value(mask_idx, x, z) < threshold:
|
||||||
|
return false
|
||||||
|
|
||||||
|
if rules.has("excluded_mask"):
|
||||||
|
var mask_idx: int = rules["excluded_mask"]
|
||||||
|
var threshold: float = rules.get("exclude_threshold", 0.5)
|
||||||
|
if get_mask_value(mask_idx, x, z) >= threshold:
|
||||||
|
return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func get_density_multiplier(x: int, z: int, rules: Dictionary) -> float:
|
||||||
|
var multiplier := 1.0
|
||||||
|
|
||||||
|
if rules.has("slope_density_curve"):
|
||||||
|
var slope := get_calculated_slope(x, z)
|
||||||
|
var curve: Curve = rules["slope_density_curve"]
|
||||||
|
multiplier *= curve.sample(clampf(slope / 90.0, 0.0, 1.0))
|
||||||
|
|
||||||
|
if rules.has("rail_distance_curve"):
|
||||||
|
var dist := get_rail_distance(x, z)
|
||||||
|
var curve: Curve = rules["rail_distance_curve"]
|
||||||
|
var max_dist: float = rules.get("rail_distance_max", 100.0)
|
||||||
|
multiplier *= curve.sample(clampf(dist / max_dist, 0.0, 1.0))
|
||||||
|
|
||||||
|
if rules.has("density_mask"):
|
||||||
|
var mask_idx: int = rules["density_mask"]
|
||||||
|
multiplier *= get_mask_value(mask_idx, x, z)
|
||||||
|
|
||||||
|
return multiplier
|
||||||
1
proc-terrain/terrain_props_data.gd.uid
Normal file
1
proc-terrain/terrain_props_data.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://csau51iw3gxam
|
||||||
106
proc-terrain/terrain_toy.tscn
Normal file
106
proc-terrain/terrain_toy.tscn
Normal file
File diff suppressed because one or more lines are too long
50
proc-terrain/triplanar_slope.gdshader
Normal file
50
proc-terrain/triplanar_slope.gdshader
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
shader_type spatial;
|
||||||
|
render_mode cull_back;
|
||||||
|
|
||||||
|
group_uniforms textures;
|
||||||
|
uniform sampler2D texture_top : source_color, filter_linear_mipmap, repeat_enable;
|
||||||
|
uniform sampler2D texture_side : source_color, filter_linear_mipmap, repeat_enable;
|
||||||
|
|
||||||
|
group_uniforms blending;
|
||||||
|
uniform float blend_sharpness : hint_range(0.1, 20.0) = 5.0;
|
||||||
|
uniform float slope_threshold : hint_range(0.0, 1.0) = 0.7;
|
||||||
|
|
||||||
|
group_uniforms uv_scaling;
|
||||||
|
uniform vec3 uv_scale = vec3(0.1, 0.1, 0.1);
|
||||||
|
uniform vec2 texture_top_tiling = vec2(1.0, 1.0);
|
||||||
|
uniform vec2 texture_side_tiling = vec2(1.0, 1.0);
|
||||||
|
|
||||||
|
varying vec3 world_pos;
|
||||||
|
varying vec3 world_normal;
|
||||||
|
|
||||||
|
void vertex() {
|
||||||
|
world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
|
||||||
|
world_normal = normalize((MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec3 abs_normal = abs(world_normal);
|
||||||
|
|
||||||
|
vec2 uv_xz = world_pos.xz * uv_scale.x * texture_top_tiling;
|
||||||
|
vec2 uv_xy = world_pos.xy * uv_scale.y * texture_side_tiling;
|
||||||
|
vec2 uv_zy = world_pos.zy * uv_scale.z * texture_side_tiling;
|
||||||
|
|
||||||
|
vec3 weights = pow(abs_normal, vec3(blend_sharpness));
|
||||||
|
weights /= (weights.x + weights.y + weights.z);
|
||||||
|
|
||||||
|
vec4 tex_xz = texture(texture_top, uv_xz);
|
||||||
|
vec4 tex_xy = texture(texture_side, uv_xy);
|
||||||
|
vec4 tex_zy = texture(texture_side, uv_zy);
|
||||||
|
|
||||||
|
vec4 triplanar_color = tex_xz * weights.y + tex_xy * weights.z + tex_zy * weights.x;
|
||||||
|
|
||||||
|
float up_factor = dot(world_normal, vec3(0.0, 1.0, 0.0));
|
||||||
|
float slope_blend = smoothstep(slope_threshold - 0.1, slope_threshold + 0.1, up_factor);
|
||||||
|
|
||||||
|
vec4 top_color = texture(texture_top, uv_xz);
|
||||||
|
vec4 side_color = (tex_xy * weights.z + tex_zy * weights.x) / max(weights.z + weights.x, 0.001);
|
||||||
|
|
||||||
|
vec4 final_color = mix(side_color, top_color, slope_blend);
|
||||||
|
|
||||||
|
ALBEDO = final_color.rgb;
|
||||||
|
}
|
||||||
1
proc-terrain/triplanar_slope.gdshader.uid
Normal file
1
proc-terrain/triplanar_slope.gdshader.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://d4b4lfd2r51iu
|
||||||
Reference in New Issue
Block a user