diff options
Diffstat (limited to 'game/addons/zylann.hterrain/hterrain_detail_layer.gd')
-rw-r--r-- | game/addons/zylann.hterrain/hterrain_detail_layer.gd | 742 |
1 files changed, 742 insertions, 0 deletions
diff --git a/game/addons/zylann.hterrain/hterrain_detail_layer.gd b/game/addons/zylann.hterrain/hterrain_detail_layer.gd new file mode 100644 index 0000000..2dee704 --- /dev/null +++ b/game/addons/zylann.hterrain/hterrain_detail_layer.gd @@ -0,0 +1,742 @@ +@tool +extends Node3D + +# Child node of the terrain, used to render numerous small objects on the ground +# such as grass or rocks. They do so by using a texture covering the terrain +# (a "detail map"), which is found in the terrain data itself. +# A terrain can have multiple detail maps, and you can choose which one will be +# used with `layer_index`. +# Details use instanced rendering within their own chunk grid, scattered around +# the player. Importantly, the position and rotation of this node don't matter, +# and they also do NOT scale with map scale. Indeed, scaling the heightmap +# doesn't mean we want to scale grass blades (which is not a use case I know of). + +const HTerrainData = preload("./hterrain_data.gd") +const HT_DirectMultiMeshInstance = preload("./util/direct_multimesh_instance.gd") +const HT_DirectMeshInstance = preload("./util/direct_mesh_instance.gd") +const HT_Util = preload("./util/util.gd") +const HT_Logger = preload("./util/logger.gd") +# TODO Can't preload because it causes the plugin to fail loading if assets aren't imported +const DEFAULT_MESH_PATH = "res://addons/zylann.hterrain/models/grass_quad.obj" + +# Cannot use `const` because `HTerrain` depends on the current script +var HTerrain = load("res://addons/zylann.hterrain/hterrain.gd") + +const CHUNK_SIZE = 32 +const DEFAULT_SHADER_PATH = "res://addons/zylann.hterrain/shaders/detail.gdshader" +const DEBUG = false + +# These parameters are considered built-in, +# they are managed internally so they are not directly exposed +const _API_SHADER_PARAMS = { + "u_terrain_heightmap": true, + "u_terrain_detailmap": true, + "u_terrain_normalmap": true, + "u_terrain_globalmap": true, + "u_terrain_inverse_transform": true, + "u_terrain_normal_basis": true, + "u_albedo_alpha": true, + "u_view_distance": true, + "u_ambient_wind": true +} + +# TODO Should be renamed `map_index` +# Which detail map this layer will use +@export var layer_index := 0: + get: + return layer_index + set(v): + if layer_index == v: + return + layer_index = v + if is_inside_tree(): + _update_material() + HT_Util.update_configuration_warning(self, false) + + +# Texture to render on the detail meshes. +@export var texture : Texture: + get: + return texture + set(tex): + texture = tex + _material.set_shader_parameter("u_albedo_alpha", tex) + + +# How far detail meshes can be seen. +# TODO Improve speed of _get_chunk_aabb() so we can increase the limit +# See https://github.com/Zylann/godot_heightmap_plugin/issues/155 +@export_range(1.0, 500.0) var view_distance := 100.0: + get: + return view_distance + set(v): + if view_distance == v: + return + view_distance = maxf(v, 1.0) + if is_inside_tree(): + _update_material() + + +# Custom shader to replace the default one. +@export var custom_shader : Shader: + get: + return custom_shader + set(shader): + if custom_shader == shader: + return + custom_shader = shader + if custom_shader == null: + _material.shader = load(DEFAULT_SHADER_PATH) + else: + _material.shader = custom_shader + + if Engine.is_editor_hint(): + # Ability to fork default shader + if shader.code == "": + shader.code = _default_shader.code + + +# Density modifier, to make more or less detail meshes appear overall. +@export_range(0, 10) var density := 4.0: + get: + return density + set(v): + v = clampf(v, 0, 10) + if v == density: + return + density = v + _multimesh_need_regen = true + + +# Mesh used for every detail instance (for example, every grass patch). +# If not assigned, an internal quad mesh will be used. +# I would have called it `mesh` but that's too broad and conflicts with local vars ._. +@export var instance_mesh : Mesh: + get: + return instance_mesh + set(p_mesh): + if p_mesh == instance_mesh: + return + instance_mesh = p_mesh + _multimesh.mesh = _get_used_mesh() + + +# Exposes rendering layers, similar to `VisualInstance.layers` +# (IMO this annotation is not specific enough, something might be off...) +@export_flags_3d_render var render_layers := 1: + get: + return render_layers + set(mask): + render_layers = mask + for k in _chunks: + var chunk = _chunks[k] + chunk.set_layer_mask(mask) + + +# Exposes shadow casting setting. +# Possible values are the same as the enum `GeometryInstance.SHADOW_CASTING_SETTING_*`. +# TODO Casting to `int` should not be necessary! Had to do it otherwise GDScript complains... +@export_enum("Off", "On", "DoubleSided", "ShadowsOnly") \ + var cast_shadow := int(GeometryInstance3D.SHADOW_CASTING_SETTING_ON): + get: + return cast_shadow + set(option): + if option == cast_shadow: + return + cast_shadow = option + for k in _chunks: + var mmi : HT_DirectMultiMeshInstance = _chunks[k] + mmi.set_cast_shadow(option) + + +var _material: ShaderMaterial = null +var _default_shader: Shader = null + +# Vector2 => DirectMultiMeshInstance +var _chunks := {} + +var _multimesh: MultiMesh +var _multimesh_need_regen = true +var _multimesh_instance_pool := [] +var _ambient_wind_time := 0.0 +#var _auto_pick_index_on_enter_tree := Engine.is_editor_hint() +var _debug_wirecube_mesh: Mesh = null +var _debug_cubes := [] +var _logger := HT_Logger.get_for(self) + + +func _init(): + _default_shader = load(DEFAULT_SHADER_PATH) + _material = ShaderMaterial.new() + _material.shader = _default_shader + + _multimesh = MultiMesh.new() + _multimesh.transform_format = MultiMesh.TRANSFORM_3D + # TODO Godot 3 had the option to specify color format, but Godot 4 no longer does... + # I only need 8-bit, but Godot 4 uses 32-bit components colors... + #_multimesh.color_format = MultiMesh.COLOR_8BIT + _multimesh.use_colors = true + + +func _enter_tree(): + var terrain = _get_terrain() + if terrain != null: + terrain.transform_changed.connect(_on_terrain_transform_changed) + + #if _auto_pick_index_on_enter_tree: + # _auto_pick_index_on_enter_tree = false + # _auto_pick_index() + + terrain._internal_add_detail_layer(self) + + _update_material() + + +func _exit_tree(): + var terrain = _get_terrain() + if terrain != null: + terrain.transform_changed.disconnect(_on_terrain_transform_changed) + terrain._internal_remove_detail_layer(self) + _update_material() + for k in _chunks.keys(): + _recycle_chunk(k) + _chunks.clear() + + +#func _auto_pick_index(): +# # Automatically pick an unused layer +# +# var terrain = _get_terrain() +# if terrain == null: +# return +# +# var terrain_data = terrain.get_data() +# if terrain_data == null or terrain_data.is_locked(): +# return +# +# var auto_index := layer_index +# var others = terrain.get_detail_layers() +# +# if len(others) > 0: +# var used_layers := [] +# for other in others: +# used_layers.append(other.layer_index) +# used_layers.sort() +# +# auto_index = used_layers[-1] +# for i in range(1, len(used_layers)): +# if used_layers[i - 1] - used_layers[i] > 1: +# # Found a hole, take it instead +# auto_index = used_layers[i] - 1 +# break +# +# print("Auto picked ", auto_index, " ") +# layer_index = auto_index + + +func _get_property_list() -> Array: + # Dynamic properties coming from the shader + var props := [] + if _material != null: + var shader_params = RenderingServer.get_shader_parameter_list(_material.shader.get_rid()) + for p in shader_params: + if _API_SHADER_PARAMS.has(p.name): + continue + var cp := {} + for k in p: + cp[k] = p[k] + cp.name = str("shader_params/", p.name) + props.append(cp) + return props + + +func _get(key: StringName): + var key_str := String(key) + if key_str.begins_with("shader_params/"): + var param_name = key_str.substr(len("shader_params/")) + return get_shader_param(param_name) + + +func _set(key: StringName, v): + var key_str := String(key) + if key_str.begins_with("shader_params/"): + var param_name = key_str.substr(len("shader_params/")) + set_shader_param(param_name, v) + + +func get_shader_param(param_name: String): + return _material.get_shader_parameter(param_name) + + +func set_shader_param(param_name: String, v): + _material.set_shader_parameter(param_name, v) + + +func _get_terrain(): + if is_inside_tree(): + return get_parent() + return null + + +# Compat +func set_texture(tex: Texture): + texture = tex + + +# Compat +func get_texture() -> Texture: + return texture + + +# Compat +func set_layer_index(v: int): + layer_index = v + + +# Compat +func get_layer_index() -> int: + return layer_index + + +# Compat +func set_view_distance(v: float): + return view_distance + + +# Compat +func get_view_distance() -> float: + return view_distance + + +# Compat +func set_custom_shader(shader: Shader): + custom_shader = shader + + +# Compat +func get_custom_shader() -> Shader: + return custom_shader + + +# Compat +func set_instance_mesh(p_mesh: Mesh): + instance_mesh = p_mesh + + +# Compat +func get_instance_mesh() -> Mesh: + return instance_mesh + + +# Compat +func set_render_layer_mask(mask: int): + render_layers = mask + + +# Compat +func get_render_layer_mask() -> int: + return render_layers + + +func _get_used_mesh() -> Mesh: + if instance_mesh == null: + var mesh = load(DEFAULT_MESH_PATH) as Mesh + if mesh == null: + _logger.error(str("Failed to load default mesh: ", DEFAULT_MESH_PATH)) + return mesh + return instance_mesh + + +# Compat +func set_density(v: float): + density = v + + +# Compat +func get_density() -> float: + return density + + +# Updates texture references and values that come from the terrain itself. +# This is typically used when maps are being swapped around in terrain data, +# so we can restore texture references that may break. +func update_material(): + _update_material() + # Formerly update_ambient_wind, reset + + +func _notification(what: int): + match what: + NOTIFICATION_ENTER_WORLD: + _set_world(get_world_3d()) + + NOTIFICATION_EXIT_WORLD: + _set_world(null) + + NOTIFICATION_VISIBILITY_CHANGED: + _set_visible(visible) + + NOTIFICATION_PREDELETE: + # Force DirectMeshInstances to be destroyed before the material. + # Otherwise it causes RenderingServer errors... + _chunks.clear() + _multimesh_instance_pool.clear() + + +func _set_visible(v: bool): + for k in _chunks: + var chunk = _chunks[k] + chunk.set_visible(v) + + +func _set_world(w: World3D): + for k in _chunks: + var chunk = _chunks[k] + chunk.set_world(w) + + +func _on_terrain_transform_changed(gt: Transform3D): + _update_material() + + var terrain = _get_terrain() + if terrain == null: + _logger.error("Detail layer is not child of a terrain!") + return + + var terrain_transform : Transform3D = terrain.get_internal_transform() + + # Update AABBs and transforms, because scale might have changed + for k in _chunks: + var mmi = _chunks[k] + var aabb = _get_chunk_aabb(terrain, Vector3(k.x * CHUNK_SIZE, 0, k.y * CHUNK_SIZE)) + # Nullify XZ translation because that's done by transform already + aabb.position.x = 0 + aabb.position.z = 0 + mmi.set_aabb(aabb) + mmi.set_transform(_get_chunk_transform(terrain_transform, k.x, k.y)) + + +func process(delta: float, viewer_pos: Vector3): + var terrain = _get_terrain() + if terrain == null: + _logger.error("DetailLayer processing while terrain is null!") + return + + if _multimesh_need_regen: + _regen_multimesh() + _multimesh_need_regen = false + # Crash workaround for Godot 3.1 + # See https://github.com/godotengine/godot/issues/32500 + for k in _chunks: + var mmi = _chunks[k] + mmi.set_multimesh(_multimesh) + + # Detail layers are unaffected by ground map_scale + var terrain_transform_without_map_scale : Transform3D = \ + terrain.get_internal_transform_unscaled() + var local_viewer_pos := terrain_transform_without_map_scale.affine_inverse() * viewer_pos + + var viewer_cx = local_viewer_pos.x / CHUNK_SIZE + var viewer_cz = local_viewer_pos.z / CHUNK_SIZE + + var cr = int(view_distance) / CHUNK_SIZE + 1 + + var cmin_x = viewer_cx - cr + var cmin_z = viewer_cz - cr + var cmax_x = viewer_cx + cr + var cmax_z = viewer_cz + cr + + var map_res = terrain.get_data().get_resolution() + var map_scale = terrain.map_scale + + var terrain_size_x = map_res * map_scale.x + var terrain_size_z = map_res * map_scale.z + + var terrain_chunks_x = terrain_size_x / CHUNK_SIZE + var terrain_chunks_z = terrain_size_z / CHUNK_SIZE + + cmin_x = clampi(cmin_x, 0, terrain_chunks_x) + cmin_z = clampi(cmin_z, 0, terrain_chunks_z) + + if DEBUG and visible: + _debug_cubes.clear() + for cz in range(cmin_z, cmax_z): + for cx in range(cmin_x, cmax_x): + _add_debug_cube(terrain, _get_chunk_aabb(terrain, Vector3(cx, 0, cz) * CHUNK_SIZE)) + + for cz in range(cmin_z, cmax_z): + for cx in range(cmin_x, cmax_x): + + var cpos2d = Vector2(cx, cz) + if _chunks.has(cpos2d): + continue + + var aabb = _get_chunk_aabb(terrain, Vector3(cx, 0, cz) * CHUNK_SIZE) + var d = (aabb.position + 0.5 * aabb.size).distance_to(local_viewer_pos) + + if d < view_distance: + _load_chunk(terrain_transform_without_map_scale, cx, cz, aabb) + + var to_recycle = [] + + for k in _chunks: + var chunk = _chunks[k] + var aabb = _get_chunk_aabb(terrain, Vector3(k.x, 0, k.y) * CHUNK_SIZE) + var d = (aabb.position + 0.5 * aabb.size).distance_to(local_viewer_pos) + if d > view_distance: + to_recycle.append(k) + + for k in to_recycle: + _recycle_chunk(k) + + # Update time manually, so we can accelerate the animation when strength is increased, + # without causing phase jumps (which would be the case if we just scaled TIME) + var ambient_wind_frequency = 1.0 + 3.0 * terrain.ambient_wind + _ambient_wind_time += delta * ambient_wind_frequency + var awp = _get_ambient_wind_params() + _material.set_shader_parameter("u_ambient_wind", awp) + + +# Gets local-space AABB of a detail chunk. +# This only apply map_scale in Y, because details are not affected by X and Z map scale. +func _get_chunk_aabb(terrain, lpos: Vector3): + var terrain_scale = terrain.map_scale + var terrain_data = terrain.get_data() + var origin_cells_x := int(lpos.x / terrain_scale.x) + var origin_cells_z := int(lpos.z / terrain_scale.z) + var size_cells_x := int(CHUNK_SIZE / terrain_scale.x) + var size_cells_z := int(CHUNK_SIZE / terrain_scale.z) + + var aabb = terrain_data.get_region_aabb( + origin_cells_x, origin_cells_z, size_cells_x, size_cells_z) + + aabb.position = Vector3(lpos.x, lpos.y + aabb.position.y * terrain_scale.y, lpos.z) + aabb.size = Vector3(CHUNK_SIZE, aabb.size.y * terrain_scale.y, CHUNK_SIZE) + return aabb + + +func _get_chunk_transform(terrain_transform: Transform3D, cx: int, cz: int) -> Transform3D: + var lpos := Vector3(cx, 0, cz) * CHUNK_SIZE + # `terrain_transform` should be the terrain's internal transform, without `map_scale`. + var trans := Transform3D( + terrain_transform.basis, + terrain_transform.origin + terrain_transform.basis * lpos) + return trans + + +func _load_chunk(terrain_transform_without_map_scale: Transform3D, cx: int, cz: int, aabb: AABB): + aabb.position.x = 0 + aabb.position.z = 0 + + var mmi = null + if len(_multimesh_instance_pool) != 0: + mmi = _multimesh_instance_pool[-1] + _multimesh_instance_pool.pop_back() + else: + mmi = HT_DirectMultiMeshInstance.new() + mmi.set_world(get_world_3d()) + mmi.set_multimesh(_multimesh) + + var trans := _get_chunk_transform(terrain_transform_without_map_scale, cx, cz) + + mmi.set_material_override(_material) + mmi.set_transform(trans) + mmi.set_aabb(aabb) + mmi.set_layer_mask(render_layers) + mmi.set_cast_shadow(cast_shadow) + mmi.set_visible(visible) + + _chunks[Vector2(cx, cz)] = mmi + + +func _recycle_chunk(cpos2d: Vector2): + var mmi = _chunks[cpos2d] + _chunks.erase(cpos2d) + mmi.set_visible(false) + _multimesh_instance_pool.append(mmi) + + +func _get_ambient_wind_params() -> Vector2: + var aw = 0.0 + var terrain = _get_terrain() + if terrain != null: + aw = terrain.ambient_wind + # amplitude, time + return Vector2(aw, _ambient_wind_time) + + +func _update_material(): + # Sets API shader properties. Custom properties are assumed to be set already + _logger.debug("Updating detail layer material") + + var terrain_data = null + var terrain = _get_terrain() + var it = Transform3D() + var normal_basis = Basis() + + if terrain != null: + var gt = terrain.get_internal_transform() + it = gt.affine_inverse() + terrain_data = terrain.get_data() + # This is needed to properly transform normals if the terrain is scaled. + # However we don't want to pick up rotation because it's already factored in the instance + #normal_basis = gt.basis.inverse().transposed() + normal_basis = Basis().scaled(terrain.map_scale).inverse().transposed() + + var mat = _material + + mat.set_shader_parameter("u_terrain_inverse_transform", it) + mat.set_shader_parameter("u_terrain_normal_basis", normal_basis) + mat.set_shader_parameter("u_albedo_alpha", texture) + mat.set_shader_parameter("u_view_distance", view_distance) + mat.set_shader_parameter("u_ambient_wind", _get_ambient_wind_params()) + + var heightmap_texture = null + var normalmap_texture = null + var detailmap_texture = null + var globalmap_texture = null + + if terrain_data != null: + if terrain_data.is_locked(): + _logger.error("Terrain data locked, can't update detail layer now") + return + + heightmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_HEIGHT) + normalmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_NORMAL) + + if layer_index < terrain_data.get_map_count(HTerrainData.CHANNEL_DETAIL): + detailmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_DETAIL, layer_index) + + if terrain_data.get_map_count(HTerrainData.CHANNEL_GLOBAL_ALBEDO) > 0: + globalmap_texture = terrain_data.get_texture(HTerrainData.CHANNEL_GLOBAL_ALBEDO) + else: + _logger.error("Terrain data is null, can't update detail layer completely") + + mat.set_shader_parameter("u_terrain_heightmap", heightmap_texture) + mat.set_shader_parameter("u_terrain_detailmap", detailmap_texture) + mat.set_shader_parameter("u_terrain_normalmap", normalmap_texture) + mat.set_shader_parameter("u_terrain_globalmap", globalmap_texture) + + +func _add_debug_cube(terrain: Node3D, aabb: AABB): + var world : World3D = terrain.get_world_3d() + + if _debug_wirecube_mesh == null: + _debug_wirecube_mesh = HT_Util.create_wirecube_mesh() + var mat := StandardMaterial3D.new() + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _debug_wirecube_mesh.surface_set_material(0, mat) + + var debug_cube := HT_DirectMeshInstance.new() + debug_cube.set_mesh(_debug_wirecube_mesh) + debug_cube.set_world(world) + #aabb.position.y += 0.2*randf() + debug_cube.set_transform(Transform3D(Basis().scaled(aabb.size), aabb.position)) + + _debug_cubes.append(debug_cube) + + +func _regen_multimesh(): + # We modify the existing multimesh instead of replacing it. + # DirectMultiMeshInstance does not keep a strong reference to them, + # so replacing would break pooled instances. + _generate_multimesh(CHUNK_SIZE, density, _get_used_mesh(), _multimesh) + + +func is_layer_index_valid() -> bool: + var terrain = _get_terrain() + if terrain == null: + return false + var data = terrain.get_data() + if data == null: + return false + return layer_index >= 0 and layer_index < data.get_map_count(HTerrainData.CHANNEL_DETAIL) + + +func _get_configuration_warnings() -> PackedStringArray: + var warnings := PackedStringArray() + + var terrain = _get_terrain() + if not (is_instance_of(terrain, HTerrain)): + warnings.append("This node must be child of an HTerrain node") + return warnings + + var data = terrain.get_data() + if data == null: + warnings.append("The terrain has no data") + return warnings + + if data.get_map_count(HTerrainData.CHANNEL_DETAIL) == 0: + warnings.append("The terrain does not have any detail map") + return warnings + + if layer_index < 0 or layer_index >= data.get_map_count(HTerrainData.CHANNEL_DETAIL): + warnings.append("Layer index is out of bounds") + return warnings + + var tex = data.get_texture(HTerrainData.CHANNEL_DETAIL, layer_index) + if tex == null: + warnings.append("The terrain does not have a map assigned in slot {0}" \ + .format([layer_index])) + + return warnings + + +# Compat +func set_cast_shadow(option: int): + cast_shadow = option + + +# Compat +func get_cast_shadow() -> int: + return cast_shadow + + +static func _generate_multimesh(resolution: int, density: float, mesh: Mesh, multimesh: MultiMesh): + assert(multimesh != null) + + var position_randomness := 0.5 + var scale_randomness := 0.0 + #var color_randomness = 0.5 + + var cell_count := resolution * resolution + var idensity := int(density) + var random_instance_count := int(cell_count * (density - floorf(density))) + var total_instance_count := cell_count * idensity + random_instance_count + + multimesh.instance_count = total_instance_count + multimesh.mesh = mesh + + # First pass ensures uniform spread + var i := 0 + for z in resolution: + for x in resolution: + for j in idensity: + + var pos := Vector3(x, 0, z) + pos.x += randf_range(-position_randomness, position_randomness) + pos.z += randf_range(-position_randomness, position_randomness) + + multimesh.set_instance_color(i, Color(1, 1, 1)) + multimesh.set_instance_transform(i, \ + Transform3D(_get_random_instance_basis(scale_randomness), pos)) + i += 1 + + # Second pass adds the rest + for j in random_instance_count: + var pos = Vector3(randf_range(0, resolution), 0, randf_range(0, resolution)) + multimesh.set_instance_color(i, Color(1, 1, 1)) + multimesh.set_instance_transform(i, \ + Transform3D(_get_random_instance_basis(scale_randomness), pos)) + i += 1 + + +static func _get_random_instance_basis(scale_randomness: float) -> Basis: + var sr := randf_range(0, scale_randomness) + var s := 1.0 + (sr * sr * sr * sr * sr) * 50.0 + + var basis := Basis() + basis = basis.scaled(Vector3(1, s, 1)) + basis = basis.rotated(Vector3(0, 1, 0), randf_range(0, PI)) + + return basis |