diff --git a/.workbuddy/memory/2026-09-23.md b/.workbuddy/memory/2026-09-23.md index 287de8d..b33c061 100644 --- a/.workbuddy/memory/2026-09-23.md +++ b/.workbuddy/memory/2026-09-23.md @@ -232,3 +232,13 @@ Godot 不会把 shader 的 uniform 默认值灌进材质实例 —— 所以别 海水运动速度(按默认值算):大浪相位速度 ≈ 6.7 m/s(波长 42 m), 细纹漂移 ≈ 0.5 m/s,噪点漂移 ≈ 0.49 m/s。大浪看得清,细碎波光是缓慢流动。 + +## 海洋应用到主场景(world/main.tscn) + +- `world/ocean/sea.tscn` + `sea.gd`:可复用海面(Water 近平面 300/120 格 + WaterFar 6000/12 格 + water_far_material.tres),实例化即用、零接线。sea.gd 自动收集带 `world_time` uniform 的材质、自动找 `Sun` 节点、近平面按 2.5 m 步长跟随 viewport 相机。 +- `world/ocean/island_mesa.tres`:台地岛材质(island.gdshader 新增 `plateau` uniform,平顶半径占比;默认 0 = 原锥形岛,向后兼容)。 +- `island.gdshader`:新增 plateau;主场景取值 radius 40 / plateau 0.72 / height 1.5 / dip 14 / detail 0.25。 +- `main.tscn` 重写:Ground 碰撞盒 40→**58**(与平顶 r≈28.8 对齐),**删除 GroundMesh**(灰色平板,会和岛平顶 z-fight),新增 `Island`(y=-1.5)与 `Sea` 实例(y=-1.5)。 +- **关键坐标系**:海平面节点摆在 y=-1.5,岛 `island_height=1.5` → 平顶世界 y=0 = 原地面高度。玩家/碰撞/联机逻辑零改动,实测 player_y=0、on_floor=true。 +- **修了一个真 bug(两个场景都有)**:「指向太阳」是平行光节点的 **+Z** 不是 -Z。之前 sea/ocean_demo 传的是 -basis.z(朝下),波光整片消失且不报错。实测 main 的 Sun basis.z y=+0.766 才是对的。 +- 验证:实机 host_game,world_time 1.2 s 推进 1.201,近平面跟随相机 (0,3.86)→snap(0,5),玩家传送 (27.5,1,0) 仍站在平顶 y≈0,岸线沙带/泡沫/海天交界截图正常,日志无错误。 diff --git a/world/main.tscn b/world/main.tscn index 1bb02f0..1ca8f23 100644 --- a/world/main.tscn +++ b/world/main.tscn @@ -3,18 +3,16 @@ [ext_resource type="Script" uid="uid://11rbhffe5dhv" path="res://world/main.gd" id="1_hyim0"] [ext_resource type="PackedScene" uid="uid://bekuuw153mv03" path="res://character/player/player.tscn" id="2_4f7fj"] [ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"] +[ext_resource type="PackedScene" path="res://world/ocean/sea.tscn" id="3_sea"] +[ext_resource type="Material" path="res://world/ocean/island_mesa.tres" id="4_island"] [sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"] -size = Vector3(40, 0.5, 40) +size = Vector3(58, 0.5, 58) -[sub_resource type="PlaneMesh" id="PlaneMesh_4f7fj"] -size = Vector2(40, 40) -subdivide_width = 8 -subdivide_depth = 8 - -[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_spvjk"] -albedo_color = Color(0.5529412, 0.5803922, 0.61960787, 1) -roughness = 0.9 +[sub_resource type="PlaneMesh" id="IslandMesh"] +size = Vector2(120, 120) +subdivide_width = 160 +subdivide_depth = 160 [sub_resource type="Environment" id="Environment_gu74f"] background_mode = 2 @@ -22,29 +20,33 @@ sky = ExtResource("2_cartoon") ambient_light_source = 3 reflected_light_source = 2 -[node name="Main" type="Node3D" unique_id=58155864] +[node name="Main" type="Node3D" unique_id=631359418] script = ExtResource("1_hyim0") player_scene = ExtResource("2_4f7fj") -[node name="Ground" type="StaticBody3D" parent="." unique_id=305091665] +[node name="Ground" type="StaticBody3D" parent="." unique_id=2052319900] -[node name="GroundShape" type="CollisionShape3D" parent="Ground" unique_id=8315700] +[node name="GroundShape" type="CollisionShape3D" parent="Ground" unique_id=1200062535] transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.25, 0) shape = SubResource("BoxShape3D_hyim0") -[node name="GroundMesh" type="MeshInstance3D" parent="Ground" unique_id=51344137] -mesh = SubResource("PlaneMesh_4f7fj") -surface_material_override/0 = SubResource("StandardMaterial3D_spvjk") +[node name="Island" type="MeshInstance3D" parent="." unique_id=2128190205] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.5, 0) +material_override = ExtResource("4_island") +mesh = SubResource("IslandMesh") -[node name="Sun" type="DirectionalLight3D" parent="." unique_id=910400852] +[node name="Sea" parent="." unique_id=1379614066 instance=ExtResource("3_sea")] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -1.5, 0) + +[node name="Sun" type="DirectionalLight3D" parent="." unique_id=174609621] transform = Transform3D(0.8660248, 0.3830344, -0.32138094, 0, 0.6427605, 0.76606715, 0.5000011, -0.66343313, 0.5566465, 0, 0, 0) light_color = Color(1, 0.97, 0.9, 1) shadow_enabled = true directional_shadow_blend_splits = true directional_shadow_max_distance = 40.0 -[node name="Environment" type="WorldEnvironment" parent="." unique_id=794103784] +[node name="Environment" type="WorldEnvironment" parent="." unique_id=1374218548] environment = SubResource("Environment_gu74f") -[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1410622657] +[node name="MultiplayerSpawner" type="MultiplayerSpawner" parent="." unique_id=1020023513] spawn_path = NodePath("..") diff --git a/world/ocean/island.gdshader b/world/ocean/island.gdshader index 6061ade..107edb0 100644 --- a/world/ocean/island.gdshader +++ b/world/ocean/island.gdshader @@ -30,6 +30,9 @@ render_mode cull_disabled, diffuse_toon, specular_disabled; group_uniforms Shape; uniform float island_radius : hint_range(2.0, 60.0) = 15.0; // 米:陆地半径 uniform float island_height : hint_range(0.5, 30.0) = 9.0; // 米:最高点高出水面多少 +// 平顶半径占 island_radius 的比例。0 = 锥形山(默认);0.7~0.85 = 台地岛。 +// 主场景要能在上面走人,必须开平顶 —— 锥形岛的斜坡无法用一块平板碰撞近似 +uniform float plateau : hint_range(0.0, 0.98) = 0.0; // 越小越像山、越大越像沙洲。调到 2 以上就成了一块浮在水上的饼 uniform float profile_power : hint_range(0.5, 5.0) = 1.30; uniform float dip_depth : hint_range(0.0, 30.0) = 12.0; // 米:裙边扎到水下多深 @@ -91,12 +94,14 @@ float island_at(vec2 p) { // 海岸线:按噪声推拉半径,否则是一个完美的圆 r += (fbm2(p * coast_scale) - 0.5) * coast_warp; - // 径向剖面:山顶有平台、山脚收得快 - float prof = 1.0 - smoothstep(0.0, 1.0, clamp(r, 0.0, 1.0)); + // 径向剖面:plateau 以内是平顶,以外收到水线。 + // 注意海岸线噪声已经加进 r 了,所以平顶边缘也是弯的,不会是一个正圆 + float prof = 1.0 - smoothstep(plateau, 1.0, clamp(r, 0.0, 1.0)); prof = pow(prof, max(profile_power, 0.001)); float h = prof * island_height; - // 地表起伏:只作用在陆地上,海岸线附近淡出,否则海岸会碎成齿状 + // 地表起伏:只作用在陆地上,海岸线附近淡出,否则海岸会碎成齿状。 + // 主场景用平顶当可走地面时,这个值要压小 —— 碰撞是平的,起伏大了人会陷进坡里 h += (fbm2(p * detail_scale) - 0.5) * detail_strength * smoothstep(1.0, 0.35, r); // 裙边:岛外主动扎到水下,免得平面边缘悬空 diff --git a/world/ocean/island_mesa.tres b/world/ocean/island_mesa.tres new file mode 100644 index 0000000..ff63d2b --- /dev/null +++ b/world/ocean/island_mesa.tres @@ -0,0 +1,20 @@ +[gd_resource type="ShaderMaterial" load_steps=2 format=3] + +[ext_resource type="Shader" path="res://world/ocean/island.gdshader" id="1_island"] + +; 主场景用的「台地岛」材质:与 ocean_demo 那座锥形岛共用同一个着色器,只改形状参数。 +; +; 关键的一组取值(相对水面 y=0,而节点本身摆在 y=-1.5,所以平顶落在世界 y=0): +; island_height = 1.5 平顶高出水面 1.5 米 —— 正好等于主场景 Ground 碰撞盒的顶面高度 +; plateau = 0.72 平顶半径 = 40 * 0.72 ≈ 28.8 米。地面碰撞盒放大到 58×58(半宽 29) +; 就是为了和这个平顶对齐,否则人会走到平顶外面掉下去 +; detail_strength = 0.25 起伏只有 ±12 厘米。碰撞是平板,起伏大了会看见脚陷进坡里 +; dip_depth = 14 岛外裙边扎到水下 14 米,从侧面看不到平面边缘 +[resource] +shader = ExtResource("1_island") +shader_parameter/island_radius = 40.0 +shader_parameter/island_height = 1.5 +shader_parameter/plateau = 0.72 +shader_parameter/profile_power = 1.60 +shader_parameter/dip_depth = 14.0 +shader_parameter/detail_strength = 0.25 diff --git a/world/ocean/ocean_demo.gd b/world/ocean/ocean_demo.gd index d1d3bc0..73e08b6 100644 --- a/world/ocean/ocean_demo.gd +++ b/world/ocean/ocean_demo.gd @@ -69,7 +69,8 @@ func _process(delta: float) -> void: var sun_dir := Vector3.UP if sun != null: # 平行光的照射方向是节点的 -Z,取反就是指向太阳 - sun_dir = -sun.global_transform.basis.z + # 「指向太阳」= 平行光节点的 +Z(沿 -Z 照射)。取反会让波光整片消失,且不报错 + sun_dir = sun.global_transform.basis.z for mat in _water_materials: mat.set_shader_parameter("world_time", t) mat.set_shader_parameter("sun_direction", sun_dir) diff --git a/world/ocean/sea.gd b/world/ocean/sea.gd new file mode 100644 index 0000000..c7b92a8 --- /dev/null +++ b/world/ocean/sea.gd @@ -0,0 +1,120 @@ +extends Node3D +## 可复用的海面驱动 · 只管「让海动起来」,不含任何玩法交互 +## +## 三件事: +## 1. 推进 world_time —— 波形相位统一由这里给,不用着色器的内置 TIME +## 2. 把场景里的平行光方向推给水面着色器,让波光与实际光照同向 +## 3. 近处细网格跟着当前相机平移(远处那层大平面不动) +## +## 用法:把 sea.tscn 实例化到任意场景即可,不需要接线。 +## 相机是运行时从 viewport 取的(玩家的第三人称相机、编辑器视口相机都算), +## 所以本文件不依赖任何具体玩家或相机节点。 +## +## 关于 world_time(这是本文件最重要的一个决定): +## 着色器里的 TIME 是「各进程自己的启动计时」。联机时两端启动时刻差几秒, +## 同一时刻看到的浪就不是同一片 —— 两个玩家站在一片海上,浪却不一样,一眼就看出来。 +## 所以波形相位必须来自一个「大家都同意的时钟」: +## · 现在:Time.get_ticks_msec() 减去本节点启动时刻 +## · 要联机同步时:把 _start_msec 换成主机下发的时间基准(一个 float,RPC 一次即可) +## 注意用「绝对时钟差」而不是逐帧累加 delta —— 两端帧率不同,累加会越走越偏。 +## +## 水面材质是**自动收集**的:场景里任何挂了带 world_time 的 ShaderMaterial 的 +## MeshInstance3D 都会被接管。之后再加几层水(远平面、沼泽、水坑)不用改代码。 + +@export_group("水面") +## 跟随相机的那层近水面(细网格)。留空则取名为 Water 的子节点 +@export var near_water: MeshInstance3D +## 网格对齐步长(米)。应当 ≈ 顶点间距:波形是世界坐标锚定的, +## 不对齐的话顶点会在波形上「滑动」,看上去像水面在抖 +@export var follow_snap: float = 2.5 +## 波形推进倍率。1.0 = 着色器里的默认速度;0 = 冻结 +@export var time_scale: float = 1.0 + +@export_group("光照") +## 提供太阳方向的光源。留空则自动找:父节点下的 Sun → 全场景第一个平行光 +@export var sun: DirectionalLight3D + +var _start_msec: int = 0 +var _water_materials: Array[ShaderMaterial] = [] + + +func _ready() -> void: + _start_msec = Time.get_ticks_msec() + if near_water == null: + near_water = get_node_or_null("Water") as MeshInstance3D + if sun == null: + sun = _find_sun() + _collect_water_materials() + if _water_materials.is_empty(): + push_warning("%s 没找到任何水面材质,海不会动" % get_path()) + _follow_camera() + + +func _process(_delta: float) -> void: + # 绝对时钟差,不是逐帧累加(原因见文件头) + var t := float(Time.get_ticks_msec() - _start_msec) / 1000.0 * time_scale + # 「指向太阳」= 平行光节点的 +Z。 + # 平行光沿自己的 -Z 照射(与相机朝向同一套约定),所以 +Z 才是朝天; + # 天空着色器里那个 LIGHT0_DIRECTION 也是这个方向 —— 它的 y 必须为正,否则天空会进夜色。 + # 取反是最容易犯的错:符号错了不会报错,只是波光整片消失(half-vector 朝下, + # dot(N, H) 恒为负),看上去像「波光强度调太小了」。 + var sun_dir := Vector3.UP if sun == null else sun.global_transform.basis.z + for mat in _water_materials: + mat.set_shader_parameter("world_time", t) + mat.set_shader_parameter("sun_direction", sun_dir) + _follow_camera() + + +## 把本子树里每个「声明了 world_time 的水面材质」都收进来。 +## 用 uniform 名字认水面,比按文件名或目录判断稳:改文件名、复制一份出来调参都还能认出。 +func _collect_water_materials() -> void: + _water_materials.clear() + var seen := {} + for node in find_children("*", "MeshInstance3D", true, false): + var mi := node as MeshInstance3D + if mi == null: + continue + var mat := mi.material_override as ShaderMaterial + if mat == null or mat.shader == null: + continue + var key := mat.get_instance_id() + if seen.has(key): + continue + if not _declares_world_time(mat): + continue + seen[key] = true + _water_materials.append(mat) + + +func _declares_world_time(mat: ShaderMaterial) -> bool: + for uniform in mat.shader.get_shader_uniform_list(): + if str(uniform.get("name")) == "world_time": + return true + return false + + +func _find_sun() -> DirectionalLight3D: + var parent := get_parent() + if parent != null: + var named := parent.find_child("Sun", true, false) as DirectionalLight3D + if named != null: + return named + var root := get_tree().current_scene if get_tree().current_scene != null else get_tree().root + for node in root.find_children("*", "DirectionalLight3D", true, false): + return node as DirectionalLight3D + return null + + +## 近平面跟着相机平移。均匀网格没法同时做到「近处够密」和「覆盖到地平线」, +## 所以近处用跟着相机走的小密网,远处另有一层大平面(它的 swell_amount = 0,纯平)。 +## 波形在世界空间锚定,所以平面平移不会让浪跟着滑。 +func _follow_camera() -> void: + if near_water == null: + return + var cam := get_viewport().get_camera_3d() if get_viewport() != null else null + if cam == null: + return + var step := maxf(follow_snap, 0.01) + var c := cam.global_position + near_water.global_position.x = roundf(c.x / step) * step + near_water.global_position.z = roundf(c.z / step) * step diff --git a/world/ocean/sea.gd.uid b/world/ocean/sea.gd.uid new file mode 100644 index 0000000..edd0d3e --- /dev/null +++ b/world/ocean/sea.gd.uid @@ -0,0 +1 @@ +uid://dpb6yt6qbcq8a diff --git a/world/ocean/sea.tscn b/world/ocean/sea.tscn new file mode 100644 index 0000000..36cd9bd --- /dev/null +++ b/world/ocean/sea.tscn @@ -0,0 +1,30 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://world/ocean/sea.gd" id="1_sea"] +[ext_resource type="Shader" path="res://world/ocean/water.gdshader" id="2_water"] +[ext_resource type="ShaderMaterial" path="res://world/ocean/water_far_material.tres" id="3_far"] + +[sub_resource type="PlaneMesh" id="PlaneMesh_near"] +size = Vector2(300, 300) +subdivide_width = 120 +subdivide_depth = 120 + +[sub_resource type="ShaderMaterial" id="ShaderMaterial_near"] +shader = ExtResource("2_water") + +[sub_resource type="PlaneMesh" id="PlaneMesh_far"] +size = Vector2(6000, 6000) +subdivide_width = 12 +subdivide_depth = 12 + +[node name="Sea" type="Node3D"] +script = ExtResource("1_sea") + +[node name="Water" type="MeshInstance3D" parent="."] +mesh = SubResource("PlaneMesh_near") +material_override = SubResource("ShaderMaterial_near") + +[node name="WaterFar" type="MeshInstance3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.8, 0) +mesh = SubResource("PlaneMesh_far") +material_override = ExtResource("3_far")