# Copyright © 2023-2026 Cory Petkovsek, Roope Palmroos, and Contributors. # # This is an example of using a particle shader with Terrain3D. # To use it, add `Terrain3DParticles.tscn` to your scene and assign the terrain. # Then customize the settings, materials and shader to extend it and make it your own. @tool extends Node3D #region settings ## Auto set if attached as a child of a Terrain3D node @export var terrain: Terrain3D: set(value): terrain = value _create_grid() ## Distance between instances @export_range(0.125, 2.0, 0.015625) var instance_spacing: float = 0.5: set(value): instance_spacing = clamp(round(value * 64.0) * 0.015625, 0.125, 2.0) rows = maxi(int(cell_width / instance_spacing), 1) amount = rows * rows _set_offsets() ## Width of an individual cell of the grid @export_range(8.0, 256.0, 1.0) var cell_width: float = 32.0: set(value): cell_width = clamp(value, 8.0, 256.0) rows = maxi(int(cell_width / instance_spacing), 1) amount = rows * rows min_draw_distance = 1.0 # Have to update aabb if terrain and terrain.data: var height_range: Vector2 = terrain.data.get_height_range() var height: float = height_range[0] - height_range[1] var aabb: AABB = AABB() aabb.size = Vector3(cell_width, height, cell_width) aabb.position = aabb.size * -0.5 aabb.position.y = height_range[1] for p in particle_nodes: p.custom_aabb = aabb _set_offsets() ## Grid width. Must be odd. ## Higher values cull slightly better, draw further out. @export_range(1, 15, 2) var grid_width: int = 9: set(value): grid_width = value particle_count = 1 min_draw_distance = 1.0 _create_grid() @export_storage var rows: int = 1 @export_storage var amount: int = 1: set(value): amount = value particle_count = value last_pos = Vector3.ZERO for p in particle_nodes: p.amount = amount @export_range(1, 256, 1) var process_fixed_fps: int = 30: set(value): process_fixed_fps = maxi(value, 1) for p in particle_nodes: p.fixed_fps = process_fixed_fps p.preprocess = 1.0 / float(process_fixed_fps) ## Access to process material parameters @export var process_material: ShaderMaterial ## The mesh that each particle will render @export var mesh: Mesh @export var shadow_mode: GeometryInstance3D.ShadowCastingSetting = ( GeometryInstance3D.ShadowCastingSetting.SHADOW_CASTING_SETTING_ON ): set(value): shadow_mode = value for p in particle_nodes: p.cast_shadow = value ## Override material for the particle mesh @export_custom(PROPERTY_HINT_RESOURCE_TYPE, "BaseMaterial3D,ShaderMaterial") var mesh_material_override: Material: set(value): mesh_material_override = value for p in particle_nodes: p.material_override = mesh_material_override @export_group("Info") ## The minimum distance that particles will be drawn upto ## If using fade out effects like pixel alpha this is the limit to use. @export var min_draw_distance: float = 1.0: set(value): min_draw_distance = float(cell_width * grid_width) * 0.5 ## Displays current total particle count based on Cell Width and Instance Spacing @export var particle_count: int = 1: set(value): particle_count = amount * grid_width * grid_width #endregion var offsets: Array[Vector3] var last_pos: Vector3 = Vector3.ZERO var particle_nodes: Array[GPUParticles3D] func _ready() -> void: if not terrain: var parent: Node = get_parent() if parent is Terrain3D: terrain = parent _create_grid() func _notification(what: int) -> void: if what == NOTIFICATION_PREDELETE: _destroy_grid() func _physics_process(delta: float) -> void: if terrain: var camera: Camera3D = terrain.get_camera() if camera: if last_pos.distance_squared_to(camera.global_position) > 1.0: var pos: Vector3 = camera.global_position.snapped(Vector3.ONE) _position_grid(pos) RenderingServer.material_set_param( process_material.get_rid(), "camera_position", pos ) last_pos = camera.global_position _update_process_parameters() else: set_physics_process(false) func _create_grid() -> void: _destroy_grid() if not terrain: return set_physics_process(true) _set_offsets() var hr: Vector2 = terrain.data.get_height_range() var height: float = hr.x - hr.y var aabb: AABB = AABB() aabb.size = Vector3(cell_width, height, cell_width) aabb.position = aabb.size * -0.5 aabb.position.y = hr.y var half_grid: int = grid_width / 2 # Iterating the array like this allows identifying grid position, in case setting # different mesh or materials is desired for LODs etc. for x in range(-half_grid, half_grid + 1): for z in range(-half_grid, half_grid + 1): #var ring: int = maxi(maxi(absi(x), absi(z)), 0) var particle_node = GPUParticles3D.new() particle_node.lifetime = 600.0 particle_node.amount = amount particle_node.explosiveness = 1.0 particle_node.amount_ratio = 1.0 particle_node.process_material = process_material particle_node.draw_pass_1 = mesh particle_node.speed_scale = 1.0 particle_node.custom_aabb = aabb particle_node.cast_shadow = shadow_mode particle_node.fixed_fps = process_fixed_fps # This prevent minor grid alignment errors when the camera is moving very fast particle_node.preprocess = 1.0 / float(process_fixed_fps) if mesh_material_override: particle_node.material_override = mesh_material_override particle_node.use_fixed_seed = true if x > -half_grid and z > -half_grid: # Use the same seed across all nodes particle_node.seed = particle_nodes[0].seed self.add_child(particle_node) particle_node.emitting = true particle_nodes.push_back(particle_node) last_pos = Vector3.ZERO func _set_offsets() -> void: var half_grid: int = grid_width / 2 offsets.clear() for x in range(-half_grid, half_grid + 1): for z in range(-half_grid, half_grid + 1): var offset := Vector3( float(x * rows) * instance_spacing, 0.0, float(z * rows) * instance_spacing ) offsets.append(offset) func _destroy_grid() -> void: for node: GPUParticles3D in particle_nodes: if is_instance_valid(node): node.queue_free() particle_nodes.clear() func _position_grid(pos: Vector3) -> void: for i in particle_nodes.size(): var node: GPUParticles3D = particle_nodes[i] var snap = Vector3(pos.x, 0, pos.z).snapped(Vector3.ONE) + offsets[i] node.global_position = (snap / instance_spacing).round() * instance_spacing node.reset_physics_interpolation() node.restart(true) # keep the same seed. func _update_process_parameters() -> void: if process_material: var process_rid: RID = process_material.get_rid() if terrain and process_rid.is_valid(): RenderingServer.material_set_param( process_rid, "_background_mode", terrain.material.world_background ) RenderingServer.material_set_param( process_rid, "_vertex_spacing", terrain.vertex_spacing ) RenderingServer.material_set_param( process_rid, "_vertex_density", 1.0 / terrain.vertex_spacing ) RenderingServer.material_set_param(process_rid, "_region_size", terrain.region_size) RenderingServer.material_set_param( process_rid, "_region_texel_size", 1.0 / terrain.region_size ) RenderingServer.material_set_param(process_rid, "_region_map_size", 32) RenderingServer.material_set_param( process_rid, "_region_map", terrain.data.get_region_map() ) RenderingServer.material_set_param( process_rid, "_region_locations", terrain.data.get_region_locations() ) RenderingServer.material_set_param( process_rid, "_height_maps", terrain.data.get_height_maps_rid() ) RenderingServer.material_set_param( process_rid, "_control_maps", terrain.data.get_control_maps_rid() ) RenderingServer.material_set_param( process_rid, "_color_maps", terrain.data.get_color_maps_rid() ) RenderingServer.material_set_param(process_rid, "instance_spacing", instance_spacing) RenderingServer.material_set_param(process_rid, "instance_rows", rows) RenderingServer.material_set_param(process_rid, "max_dist", min_draw_distance)