From 112eca1b7264acfc75c83df89bd7516720ce8b77 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 16 Sep 2026 00:01:24 +0900 Subject: [PATCH] =?UTF-8?q?[WL-816zg]=20=EC=84=AC=20=EB=AC=BC=EA=B0=80=20?= =?UTF-8?q?=EC=8A=A4=EC=BB=A4=ED=8A=B8(=EC=99=84=EB=A7=8C=ED=95=9C=20?= =?UTF-8?q?=EB=AA=A8=EB=9E=98=20=EA=B2=BD=EC=82=AC)=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PD 결정(2026-09-15 「물은 진행」): 데모 연못처럼 섬 가장자리가 완만한 모래 경사로 물에 잠기게. - 신규 WLShoreSkirt.cs — 열린 타일(FI Island IsUnlocked · 한 변 IslandManager.IslandSize) 발자국의 바깥 테두리 선분까지의 유클리드 거리장(부호: 안 −/밖 +)으로 격자(셀 0.5 m) 띠 메시 1장 생성. y = smoothstep(d / skirtWidth) 로 윗면(0 − skirtTopDrop 0.04) → skirtBottomY −1.8 · 안쪽 d < −0.3 은 만들지 않음 · 격자 원점을 타일 가장자리에 맞춰 틈 0 · 볼록 모서리는 자동으로 둥긂. 정점색 = d 0~0.6 풀색(islandTopMaterial _DiffuseColor) → 모래색(softDirtColor) · 물 아래 ×0.85. 멱등(발자국·값 서명) · 드로우콜 +1 · 삼각형 수 로그. - 신규 Shaders/WLShoreSkirt.shader `WL/Shore Skirt` — URP 언릿 정점색 · 불투명 · 그림자 off · DepthOnly/DepthNormals 패스 포함(물 셰이더가 깊이로 물가 띠를 만들므로 깊이 프리패스 모드에서도 보이게) · SRP Batcher 호환 · target 2.0 · 텍스처 0. - WLIslandLookSettings §0-c: shoreSkirtEnabled 1 · skirtWidth 3.0 · skirtBottomY −1.8 · skirtGrassBand 0.6 · skirtSandBlend 0.6 · skirtCell 0.5 · skirtInnerKeep 0.3 · skirtTopDrop 0.04 · skirtTopMode 1(고정 skirtTopY 0) · skirtUnderwaterMul 0.85 · skirtGrassFallback · skirtMaxGridNodes 400000 · shoreSkirtMaterial(선택). 되돌리기 = shoreSkirtEnabled 0. - WLIslandLook.Apply: SpawnSeabed 다음에 SpawnShoreSkirt · 재훑기·RequestGrassRebuild(확장) 같은 훅에서 재생성 · 로그 항목. - 원본(FarmingIsland/3DPixelArtEnvironment/Script) 0줄 · ProjectSettings 0줄 · 유니티 미실행(컴파일은 Lead). Co-Authored-By: Claude --- Assets/WL/Look/Farm/Shaders.meta | 8 + .../WL/Look/Farm/Shaders/WLShoreSkirt.shader | 204 +++++++++ .../Farm/Shaders/WLShoreSkirt.shader.meta | 9 + Assets/WL/Look/Farm/WLIslandLook.cs | 33 ++ Assets/WL/Look/Farm/WLIslandLookSettings.cs | 48 +++ Assets/WL/Look/Farm/WLShoreSkirt.cs | 395 ++++++++++++++++++ Assets/WL/Look/Farm/WLShoreSkirt.cs.meta | 11 + 7 files changed, 708 insertions(+) create mode 100644 Assets/WL/Look/Farm/Shaders.meta create mode 100644 Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader create mode 100644 Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader.meta create mode 100644 Assets/WL/Look/Farm/WLShoreSkirt.cs create mode 100644 Assets/WL/Look/Farm/WLShoreSkirt.cs.meta diff --git a/Assets/WL/Look/Farm/Shaders.meta b/Assets/WL/Look/Farm/Shaders.meta new file mode 100644 index 000000000..f34decc6f --- /dev/null +++ b/Assets/WL/Look/Farm/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3cc6fee6fe84883494fb35f29859f927 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader b/Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader new file mode 100644 index 000000000..bd5af5f9f --- /dev/null +++ b/Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader @@ -0,0 +1,204 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLShoreSkirt.shader — 섬 둘레 물가 스커트(완만한 모래 경사) 전용 URP 언릿 정점색 (WL-816zg · #816) +// +// 왜 전용 셰이더인가: +// · 색은 전부 CPU 가 정점색으로 굽는다(풀 → 모래 → 물속 어둡기). 셰이더는 그 값을 그대로 내보낸다 = 텍스처 0 · 분기 0. +// · URP Unlit 은 정점색을 읽지 않고, Lit 계열은 조명·그림자 변형이 붙어 모바일에서 비싸다. +// · 물 셰이더(Farm_Water_Demo3)가 **깊이 텍스처**로 물가 흰 띠를 만든다 → URP 가 깊이 프리패스로 도는 설정에서도 +// 경사가 깊이에 들어가야 하므로 DepthOnly · DepthNormals 패스를 함께 둔다(파이프라인이 요구할 때만 돈다 = 평소 비용 0). +// +// 모바일 제약 준수 (PD 상시 규칙 d): +// · geometry / tessellation 없음 · 불투명 · ZWrite On · 그림자 없음(ShadowCaster 패스 없음) +// · SRP Batcher 호환 (CBUFFER_START(UnityPerMaterial) — 모든 패스 동일) +// · #pragma target 2.0 · half 정밀도 · 텍스처 샘플 0회 +// +// 정점색 규약 (WLShoreSkirt.cs 가 굽는다): RGB = 최종 색(리니어 파이프라인이면 리니어 값) · A = 미사용. +// ───────────────────────────────────────────────────────────────────────────── +Shader "WL/Shore Skirt" +{ + Properties + { + _Tint ("색 배율 (정점색에 곱함)", Color) = (1, 1, 1, 1) + [Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 2 + } + + SubShader + { + Tags + { + "RenderPipeline" = "UniversalPipeline" + "RenderType" = "Opaque" + "Queue" = "Geometry" + "IgnoreProjector" = "True" + } + + // ── 본 패스: 정점색 그대로 ───────────────────────────────────────── + Pass + { + Name "ShoreSkirt" + Tags { "LightMode" = "UniversalForward" } + + Blend Off + ZWrite On + ZTest LEqual + Cull [_Cull] + + HLSLPROGRAM + #pragma target 2.0 + #pragma vertex vert + #pragma fragment frag + #pragma multi_compile_instancing + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + + CBUFFER_START(UnityPerMaterial) + half4 _Tint; + CBUFFER_END + + struct Attributes + { + float4 positionOS : POSITION; + float4 color : COLOR; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + half4 color : COLOR; + UNITY_VERTEX_OUTPUT_STEREO + }; + + Varyings vert (Attributes IN) + { + Varyings OUT = (Varyings)0; + UNITY_SETUP_INSTANCE_ID(IN); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT); + + OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz); + OUT.color = half4(IN.color.rgb, 1.0) * _Tint; + return OUT; + } + + half4 frag (Varyings IN) : SV_Target + { + return half4(IN.color.rgb, 1.0); + } + ENDHLSL + } + + // ── 깊이 전용 패스: 물 셰이더가 깊이 텍스처에서 경사를 본다 ─────── + Pass + { + Name "DepthOnly" + Tags { "LightMode" = "DepthOnly" } + + ZWrite On + ColorMask R + Cull [_Cull] + + HLSLPROGRAM + #pragma target 2.0 + #pragma vertex vertDepth + #pragma fragment fragDepth + #pragma multi_compile_instancing + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + + CBUFFER_START(UnityPerMaterial) + half4 _Tint; + CBUFFER_END + + struct AttributesDepth + { + float4 positionOS : POSITION; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct VaryingsDepth + { + float4 positionCS : SV_POSITION; + UNITY_VERTEX_OUTPUT_STEREO + }; + + VaryingsDepth vertDepth (AttributesDepth IN) + { + VaryingsDepth OUT = (VaryingsDepth)0; + UNITY_SETUP_INSTANCE_ID(IN); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT); + OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz); + return OUT; + } + + half fragDepth (VaryingsDepth IN) : SV_Target + { + return IN.positionCS.z; + } + ENDHLSL + } + + // ── 깊이+법선 패스: SSAO 등으로 URP 가 DepthNormals 프리패스를 돌릴 때도 깊이에서 빠지지 않게 ── + Pass + { + Name "DepthNormals" + Tags { "LightMode" = "DepthNormals" } + + ZWrite On + Cull [_Cull] + + HLSLPROGRAM + #pragma target 2.0 + #pragma vertex vertNormals + #pragma fragment fragNormals + #pragma multi_compile_instancing + #pragma multi_compile_fragment _ _GBUFFER_NORMALS_OCT + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Packing.hlsl" + + CBUFFER_START(UnityPerMaterial) + half4 _Tint; + CBUFFER_END + + struct AttributesNormals + { + float4 positionOS : POSITION; + float3 normalOS : NORMAL; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct VaryingsNormals + { + float4 positionCS : SV_POSITION; + float3 normalWS : TEXCOORD0; + UNITY_VERTEX_OUTPUT_STEREO + }; + + VaryingsNormals vertNormals (AttributesNormals IN) + { + VaryingsNormals OUT = (VaryingsNormals)0; + UNITY_SETUP_INSTANCE_ID(IN); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(OUT); + OUT.positionCS = TransformObjectToHClip(IN.positionOS.xyz); + OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS); + return OUT; + } + + half4 fragNormals (VaryingsNormals IN) : SV_Target + { + float3 n = normalize(IN.normalWS); + #if defined(_GBUFFER_NORMALS_OCT) + float2 octNormalWS = PackNormalOctQuadEncode(n); // [-1, +1] + float2 remapped = saturate(octNormalWS * 0.5 + 0.5); // [ 0, 1] + half3 packed = PackFloat2To888(remapped); + return half4(packed, 0.0); + #else + return half4(n, 0.0); + #endif + } + ENDHLSL + } + } + + Fallback Off +} diff --git a/Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader.meta b/Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader.meta new file mode 100644 index 000000000..ed14b89c1 --- /dev/null +++ b/Assets/WL/Look/Farm/Shaders/WLShoreSkirt.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: b56bdf1cf90550693dba8b58ec78e88d +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Look/Farm/WLIslandLook.cs b/Assets/WL/Look/Farm/WLIslandLook.cs index a1f1714d7..945c29ca0 100644 --- a/Assets/WL/Look/Farm/WLIslandLook.cs +++ b/Assets/WL/Look/Farm/WLIslandLook.cs @@ -135,6 +135,7 @@ namespace WL.Look.Farm // 🔴 816zf — 흙 데칼은 **풀보다 먼저** 만든다. 풀의 밭 제외가 이 마스크를 읽기 때문이다. if (cfg.softDirtEnabled != 0) SpawnSoftDirt(cfg, scene); if (cfg.seabedEnabled != 0) SpawnSeabed(cfg, scene); + if (cfg.shoreSkirtEnabled != 0) SpawnShoreSkirt(cfg, scene); if (cfg.grassEnabled != 0) SpawnGrass(cfg, scene); if (cfg.shoreFoamEnabled != 0) SpawnShoreFoam(cfg, scene); @@ -145,6 +146,7 @@ namespace WL.Look.Farm + " · ReferenceLook " + (ReferenceLookApplied ? "on" : "off") + " · 풀 " + WLIslandGrass.LastLog + " · 흙데칼 " + WLSoftDirt.LastLog + + " · 물가스커트 " + WLShoreSkirt.LastLog + " · 둘레거품 " + WLShoreFoam.LastLog); // 늦게 생기는 오브젝트(상인·작물·아이템)까지 다시 훑는다 @@ -175,6 +177,8 @@ namespace WL.Look.Farm if (g2 != null) { g2.Rebuild(); GrassRebuilds++; } } } + // 🔴 816zg — 물가 스커트도 같은 재훑기에서 다시 만든다(발자국이 그대로면 멱등 = 공짜). + if (cfg.shoreSkirtEnabled != 0) SpawnShoreSkirt(cfg, scene); HookIslands(cfg, scene); } } @@ -283,6 +287,12 @@ namespace WL.Look.Farm var f = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (f != null) { f.cfg = cfg; f.Rebuild(); ShoreFoamRebuilds++; } } + // 🔴 816zg — 섬이 확장되면 물가 스커트도 같은 훅으로 다시 만든다(발자국 변화 → 거리장 재계산). + if (cfg.shoreSkirtEnabled != 0) + { + var sk = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (sk != null) { sk.cfg = cfg; sk.Rebuild(); ShoreSkirtRebuilds++; } + } } public static int ShoreFoamRebuilds; @@ -359,6 +369,29 @@ namespace WL.Look.Farm SeabedLog = "바다 바닥판 y=" + go.transform.position.y.ToString("F2") + (found ? "(물 " + waterY.ToString("F2") + ")" : "(물 미발견 · 기본 -1)") + " · 깊이 " + cfg.seabedDepth + " m"; } + public static bool ShoreSkirtSpawned; + public static int ShoreSkirtRebuilds; + + /// + /// 816zg — 섬 둘레 완만한 모래 경사(물가 스커트). 바닥판·거품과 같은 자리에서 만들고 같은 훅으로 다시 만든다. + /// 열린 타일 발자국의 거리장으로 띠 메시 1장(드로우콜 +1)을 굽는다 — 본체는 . + /// + public static void SpawnShoreSkirt(WLIslandLookSettings cfg, Scene scene) + { + var s = Object.FindFirstObjectByType(FindObjectsInactive.Include); + if (s == null) + { + var go = new GameObject(WLShoreSkirt.ObjectName); + SceneManager.MoveGameObjectToScene(go, scene); + go.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + s = go.AddComponent(); + s.cfg = cfg; + s.Rebuild(); // OnEnable 이 cfg 전에 돌 수 있다 + } + else { s.cfg = cfg; s.Rebuild(); } + ShoreSkirtSpawned = true; + } + /// 섬 둘레 거품 띠 — 풀과 같은 자리에서 만들고 같은 훅으로 다시 만든다(816j2). public static void SpawnShoreFoam(WLIslandLookSettings cfg, Scene scene) { diff --git a/Assets/WL/Look/Farm/WLIslandLookSettings.cs b/Assets/WL/Look/Farm/WLIslandLookSettings.cs index a0bc3f118..4409b7f53 100644 --- a/Assets/WL/Look/Farm/WLIslandLookSettings.cs +++ b/Assets/WL/Look/Farm/WLIslandLookSettings.cs @@ -306,6 +306,54 @@ namespace WL.Look.Farm [Tooltip("바닥판 색(언릿 · sRGB). 기본 = 데모 모래색.")] public Color seabedColor = new Color(0.71f, 0.706f, 0.549f, 1f); + // ───────────────────────────────── §0-c 물가 스커트 (WL-816zg · PD 「물은 진행」 2026-09-15) + [Header("§0-c — 섬 둘레 완만한 모래 경사(물가 스커트) — 데모 연못처럼 가장자리가 모래 경사로 물에 잠긴다")] + [Tooltip("1 = 열린 타일 발자국 바깥에 거리장 기반 모래 경사 메시 1장(드로우콜 +1)을 깐다 — 둥근 윤곽 · 타일 수직 옆면 가림 · " + + "흰 물가 띠는 물 셰이더가 깊이로 만든다. 0 = 없음(지금 상태 100 % 복귀).")] + public int shoreSkirtEnabled = 1; + + [Tooltip("경사에 쓸 머티리얼(선택). 비우면 `WL/Shore Skirt` 셰이더로 런타임 머티리얼을 만든다(에셋 0줄). " + + "🔴 모바일 빌드에 이 셰이더가 들어가게 하려면 그 셰이더를 쓰는 머티리얼을 여기에 넣어 두는 것이 가장 확실하다(Always Included 대신).")] + public Material shoreSkirtMaterial; + + [Tooltip("타일 가장자리에서 바깥(물 쪽)으로 뻗는 경사 폭(m).")] + public float skirtWidth = 3.0f; + + [Tooltip("경사 바깥 끝의 높이(월드 y · m). 물(−1.0) 아래 · 바닥판(물 − seabedDepth = −1.8) 위 또는 같게.")] + public float skirtBottomY = -1.8f; + + [Tooltip("타일 가장자리에서 이 거리(m)까지는 풀색(islandTopMaterial `_DiffuseColor`), 그 뒤 모래색(softDirtColor)으로 넘어간다.")] + public float skirtGrassBand = 0.6f; + + [Tooltip("풀색 → 모래색으로 넘어가는 폭(m).")] + public float skirtSandBlend = 0.6f; + + [Tooltip("메시 격자 칸(m). 🔴 타일 한 변(8 m)을 나누는 값이어야 정점이 타일 가장자리 선 위에 정확히 놓인다(0.5 · 0.25 · 1).")] + public float skirtCell = 0.5f; + + [Tooltip("타일 안쪽으로 이 거리(m)까지만 정점을 둔다(가장자리 밑으로 살짝 들어가 틈을 막는다). 그 안쪽은 만들지 않는다(윗면 z-fight 방지).")] + public float skirtInnerKeep = 0.3f; + + [Tooltip("타일 윗면보다 이만큼(m) 낮은 곳에서 경사를 시작한다 — 윗면과 같은 높이면 z-fight. 타일 옆면이 그만큼만 보인다.")] + public float skirtTopDrop = 0.04f; + + [Tooltip("1 = 타일 윗면 높이를 아래 `skirtTopY` 고정값으로(Lead 실측 0 · 확장 애니메이션 중에도 흔들리지 않는다) · " + + "0 = 열린 타일 렌더러 bounds 윗면을 실측해 쓴다(로그에 두 값이 같이 찍힌다).")] + public int skirtTopMode = 1; + + [Tooltip("타일 윗면 높이 고정값(월드 y · m). 실측(2026-09-15) = 0. 실측 모드에서 렌더러를 못 찾을 때의 대체값이기도 하다.")] + public float skirtTopY = 0f; + + [Tooltip("물에 잠긴 정점(y < 물 높이)의 색 배율 — 젖은 모래·물속 어둡기.")] + public float skirtUnderwaterMul = 0.85f; + + [Tooltip("풀색 대체값(sRGB). 기본은 `islandTopMaterial`(Farm_IslandTop_Demo3)의 `_DiffuseColor` 를 읽고, 없을 때만 이 값을 쓴다. " + + "기본 = 816ze 들판 실측 0.62/0.82/0.485.")] + public Color skirtGrassFallback = new Color(0.62f, 0.82f, 0.485f, 1f); + + [Tooltip("거리장·메시 격자 정점 수 상한. 넘으면 칸을 두 배씩 키운다(섬이 아무리 커져도 비용이 묶인다).")] + public int skirtMaxGridNodes = 400000; + // ───────────────────────────────── §1 풀밭 [Header("§1 — 섬 타일 위 풀밭 (데모와 같은 인스턴싱)")] [Tooltip("0 이면 풀을 깔지 않는다.")] diff --git a/Assets/WL/Look/Farm/WLShoreSkirt.cs b/Assets/WL/Look/Farm/WLShoreSkirt.cs new file mode 100644 index 000000000..76769a80f --- /dev/null +++ b/Assets/WL/Look/Farm/WLShoreSkirt.cs @@ -0,0 +1,395 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLShoreSkirt.cs — 섬 둘레 완만한 모래 경사 테두리(물가 스커트) (WL-816zg · #816) +// +// PD 결정(2026-09-15 「물은 진행」) — 데모 연못처럼 섬 가장자리가 완만한 모래 경사로 물에 잠기게. +// · 둥근 윤곽 : 열린 타일 발자국의 **유클리드 거리장**으로 띠를 만든다 → 볼록 모서리가 저절로 둥글다. +// · 흰 물가 띠 : 물 셰이더(Farm_Water_Demo3)가 **깊이**로 만든다. 경사가 물 아래까지 이어져 얕은 물이 생긴다. +// · 타일 옆면 가림 : 경사가 타일 가장자리(윗면 − skirtTopDrop)에서 시작해 수직 옆면을 덮는다. +// +// ■ 만드는 법 +// 1) 열린 타일(FI `Island` · IsUnlocked && activeInHierarchy · 한 변 = IslandManager.IslandSize)의 중심을 모은다. +// 2) 발자국의 **바깥 테두리 선분**(이웃 타일이 없는 변)만 골라 둔다. 격자 정점마다 그 선분들까지의 최단 거리가 +// 부호 있는 거리 d(안쪽 −, 바깥 +)다 — 타일별 사각형 SDF 의 min 은 타일 이음매에서 「안쪽인데 0 근처」로 틀려 +// 윗면 위에 조각이 생기므로 쓰지 않는다(WLSoftDirt.SignedDist 의 「부호 있는 거리 → 띠」 방식은 그대로). +// 3) −skirtInnerKeep ≤ d ≤ skirtWidth 인 격자(셀 skirtCell)만 만들고, y = smoothstep(d / skirtWidth) 로 윗면 → skirtBottomY. +// 격자 원점을 타일 가장자리에 맞춰 정점이 가장자리 선 위에 정확히 놓인다(틈 0 · 윗면 z-fight 0). +// 4) 색 = 정점색. d 0~skirtGrassBand 는 풀색(islandTopMaterial `_DiffuseColor`) → 모래색(softDirtColor) · 물 아래 × skirtUnderwaterMul. +// 셰이더 `WL/Shore Skirt` = 언릿 정점색 · URP · DepthOnly/DepthNormals 패스 포함(물 셰이더가 깊이 텍스처에서 경사를 봐야 한다). +// 메시 1장 · 드로우콜 +1 · 삼각형 수 = LastTriangles(로그에도 남긴다). +// +// ■ 되돌리기 — `shoreSkirtEnabled = 0` 이면 만들지 않는다(지금 상태 100 %). +// +// 🔴 FI 코드 0줄 · 씬 파일 0줄 · 에셋 0줄. 메시·머티리얼은 런타임 생성이다. +// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업). +// ───────────────────────────────────────────────────────────────────────────── +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Rendering; +using FIIsland = CryingSnow.FarmingIsland.Island; +using FIIslandManager = CryingSnow.FarmingIsland.IslandManager; + +namespace WL.Look.Farm +{ + [DisallowMultipleComponent] + public sealed class WLShoreSkirt : MonoBehaviour + { + public const string ObjectName = "~WL_ShoreSkirt"; + public const string ShaderName = "WL/Shore Skirt"; + + // ── 진단(프로브·보고가 읽는다) + public static int Rebuilds; + public static int LastTiles, LastVertices, LastTriangles; + public static string LastLog = "꺼짐"; + + public WLIslandLookSettings cfg; + + MeshFilter _mf; + MeshRenderer _mr; + Mesh _mesh; + Material _inst; + bool _vertexColor; // 전용 셰이더를 찾았는가(아니면 URP Unlit 모래 단색 대체) + + static readonly int IdBaseColor = Shader.PropertyToID("_BaseColor"); + static readonly int IdColor = Shader.PropertyToID("_Color"); + static readonly int IdDiffuseColor = Shader.PropertyToID("_DiffuseColor"); + + struct Seg { public float ax, az, bx, bz; } + readonly List _tiles = new List(); // x·z = 타일 중심 · y = 렌더러 윗면 실측 + readonly List _edges = new List(); // 발자국 바깥 테두리 선분 + readonly List _verts = new List(); + readonly List _cols = new List(); + readonly List _tris = new List(); + + // 마지막으로 만든 조건 — 같으면 다시 만들지 않는다(1초 폴링·재훑기에서 공짜로 돌게). + bool _dirty = true; + long _lastTileSig; + float _lastValSig = float.NaN; + + void OnEnable() { Ensure(); _dirty = true; Rebuild(); } + void OnDisable() { if (_mr != null) _mr.enabled = false; } + + void OnDestroy() + { + if (_inst != null) Destroy(_inst); + if (_mesh != null) Destroy(_mesh); + } + + void Ensure() + { + if (_mf == null) _mf = GetComponent(); + if (_mf == null) _mf = gameObject.AddComponent(); + if (_mr == null) _mr = GetComponent(); + if (_mr == null) _mr = gameObject.AddComponent(); + _mr.shadowCastingMode = ShadowCastingMode.Off; + _mr.receiveShadows = false; + _mr.lightProbeUsage = LightProbeUsage.Off; + _mr.reflectionProbeUsage = ReflectionProbeUsage.Off; + _mr.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; + if (_mesh == null) + { + _mesh = new Mesh { name = "WL_ShoreSkirtMesh" }; + _mesh.MarkDynamic(); + _mesh.hideFlags = HideFlags.DontSave; + } + _mf.sharedMesh = _mesh; + } + + /// 다시 만든다(섬 확장·재훑기). 멱등 — 발자국·값이 그대로면 아무것도 하지 않는다. + public void Rebuild() + { + Ensure(); + if (cfg == null) cfg = WLIslandLookSettings.Instance; + if (cfg == null || cfg.enabled_ == 0 || cfg.shoreSkirtEnabled == 0) + { _mr.enabled = false; LastLog = "꺼짐"; return; } + + float size = TileSize(); + if (size <= 0.01f) { _mr.enabled = false; LastLog = "타일 크기 미확인(IslandManager 없음) — 대기"; return; } + float half = size * 0.5f; + + long tileSig = CollectTiles(out float topMeasured); + LastTiles = _tiles.Count; + if (_tiles.Count == 0) { _mr.enabled = false; LastLog = "열린 타일 0 — 대기"; return; } + + bool fixedTop = cfg.skirtTopMode != 0 || float.IsNaN(topMeasured); + float topY = fixedTop ? cfg.skirtTopY : topMeasured; + float waterY = FindWaterY(out bool waterFound); + + Color grass = ToVertexSpace(GrassColor()); + Color sand = ToVertexSpace(cfg.softDirtColor); + + // 값이 바뀌면 다시 만든다(인스펙터에서 만져도 다음 폴링에 반영된다). + float valSig = cfg.skirtWidth * 7.1f + cfg.skirtBottomY * 13.3f + cfg.skirtGrassBand * 3.7f + cfg.skirtSandBlend * 29.3f + + cfg.skirtCell * 5.9f + cfg.skirtInnerKeep * 11.9f + cfg.skirtTopDrop * 17.7f + cfg.skirtUnderwaterMul * 31.7f + + cfg.skirtMaxGridNodes * 0.001f + topY * 37.3f + waterY * 41.1f + + grass.r * 101f + grass.g * 103f + grass.b * 107f + sand.r * 109f + sand.g * 113f + sand.b * 127f; + + bool same = !_dirty && _inst != null && tileSig == _lastTileSig && Mathf.Abs(_lastValSig - valSig) < 0.0001f; + if (same) { _mr.enabled = true; return; } + + if (!EnsureMaterial()) { _mr.enabled = false; LastLog = "🔴 셰이더를 못 찾음(WL/Shore Skirt · URP Unlit 둘 다)"; return; } + + BuildEdges(size, half); + float cell = BuildMesh(half, topY, waterY, grass, sand); + + _mr.sharedMaterial = _inst; + _mr.enabled = true; + _lastTileSig = tileSig; _lastValSig = valSig; _dirty = false; + Rebuilds++; + + LastLog = "물가 스커트 — 타일 " + _tiles.Count + " · 테두리 변 " + _edges.Count + + " · 정점 " + LastVertices + " · 삼각형 " + LastTriangles + + " · 폭 " + cfg.skirtWidth.ToString("F1") + " m · 윗면 y " + topY.ToString("F2") + + (fixedTop ? "(고정 · 실측 " + (float.IsNaN(topMeasured) ? "없음" : topMeasured.ToString("F2")) + : "(실측 · 고정값 " + cfg.skirtTopY.ToString("F2")) + ")" + + " → 바닥 " + cfg.skirtBottomY.ToString("F2") + " · 물 " + waterY.ToString("F2") + (waterFound ? "" : "(미발견·대체값)") + + " · 격자 " + cell.ToString("F2") + " m · 셰이더 " + (_vertexColor ? ShaderName : "대체 URP Unlit(정점색 없음 · 모래 단색)") + + " · 드로우콜 +1"; + cfg.Log(LastLog); + } + + // ───────────────────────────────────────────────────────────────── + // 입력 수집 + // ───────────────────────────────────────────────────────────────── + static float TileSize() + { + var im = FIIslandManager.Instance; + return im != null ? im.IslandSize : 0f; + } + + /// 열린 타일 중심을 모은다. 반환 = 발자국 서명(순서 무관) — 확장으로 바뀌면 값이 바뀐다. + long CollectTiles(out float topMeasured) + { + _tiles.Clear(); + topMeasured = float.NaN; + var scene = gameObject.scene; + var islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + long sig = 17; + for (int i = 0; i < islands.Length; i++) + { + var isl = islands[i]; + if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue; + if (scene.IsValid() && isl.gameObject.scene != scene) continue; + var p = isl.transform.position; + float top = p.y; + var r = isl.GetComponent(); // 섬 본체 렌더러(FIIsland 와 같은 오브젝트 · SwapMaterials 와 같은 판별) + if (r != null) top = r.bounds.max.y; + if (float.IsNaN(topMeasured) || top > topMeasured) topMeasured = top; + _tiles.Add(new Vector3(p.x, top, p.z)); + sig += ((long)Mathf.RoundToInt(p.x * 8f) * 73856093L) ^ ((long)Mathf.RoundToInt(p.z * 8f) * 19349663L); + } + return sig ^ ((long)_tiles.Count << 40); + } + + /// 물 높이 — SpawnSeabed 와 같은 판별(씬의 `Water` 렌더러). 없으면 거품 띠의 대체값. + float FindWaterY(out bool found) + { + var scene = gameObject.scene; + var mrs = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < mrs.Length; i++) + { + var r = mrs[i]; + if (r == null || r.gameObject.name != "Water") continue; + if (scene.IsValid() && r.gameObject.scene != scene) continue; + found = true; + return r.transform.position.y; + } + found = false; + return cfg.shoreFoamFallbackY; + } + + /// 풀색 — 타일 윗면 머티리얼(Farm_IslandTop_Demo3)의 `_DiffuseColor`. 없으면 `_BaseColor`·`_Color` → 설정 대체값. + Color GrassColor() + { + var m = cfg.islandTopMaterial; + if (m != null) + { + if (m.HasProperty(IdDiffuseColor)) return m.GetColor(IdDiffuseColor); + if (m.HasProperty(IdBaseColor)) return m.GetColor(IdBaseColor); + if (m.HasProperty(IdColor)) return m.GetColor(IdColor); + } + return cfg.skirtGrassFallback; + } + + /// + /// 설정·머티리얼의 색은 sRGB 로 적힌 값이다(URP Unlit `_BaseColor` 와 같은 해석 — 바닥판·흙 데칼과 같은 숫자면 같은 색). + /// 정점색은 변환 없이 셰이더로 들어가므로 리니어 파이프라인에서는 리니어로 옮겨 넣는다. + /// + static Color ToVertexSpace(Color c) + { + return QualitySettings.activeColorSpace == ColorSpace.Linear ? c.linear : c; + } + + /// 전용 셰이더(정점색) → 없으면 URP Unlit 모래 단색 대체. 에셋은 만들지 않는다. + bool EnsureMaterial() + { + if (_inst != null) return true; + + Shader sh = cfg.shoreSkirtMaterial != null ? cfg.shoreSkirtMaterial.shader : null; + if (sh == null) sh = Shader.Find(ShaderName); + if (sh == null) sh = Shader.Find("Universal Render Pipeline/Unlit"); + if (sh == null) sh = Shader.Find("Unlit/Color"); + if (sh == null) return false; + _vertexColor = sh.name == ShaderName; + + _inst = cfg.shoreSkirtMaterial != null && cfg.shoreSkirtMaterial.shader == sh + ? new Material(cfg.shoreSkirtMaterial) : new Material(sh); + _inst.name = "WL_ShoreSkirt(runtime)"; + _inst.hideFlags = HideFlags.DontSave; + if (!_vertexColor) + { + // 대체 셰이더의 색 프로퍼티는 sRGB 해석 → 설정값을 그대로 넣는다(흙 데칼과 같은 색) + if (_inst.HasProperty(IdBaseColor)) _inst.SetColor(IdBaseColor, cfg.softDirtColor); + if (_inst.HasProperty(IdColor)) _inst.SetColor(IdColor, cfg.softDirtColor); + } + return true; + } + + // ───────────────────────────────────────────────────────────────── + // 거리장 — 발자국 바깥 테두리 선분까지의 유클리드 거리(정확) · 부호 = 타일 안(−)/밖(+) + // ───────────────────────────────────────────────────────────────── + void BuildEdges(float size, float half) + { + _edges.Clear(); + float tol = size * 0.25f; // 이웃 판정 허용 오차(타일 격자는 정수 배) + for (int i = 0; i < _tiles.Count; i++) + { + float cx = _tiles[i].x, cz = _tiles[i].z; + if (!HasTile(cx + size, cz, tol)) _edges.Add(new Seg { ax = cx + half, az = cz - half, bx = cx + half, bz = cz + half }); + if (!HasTile(cx - size, cz, tol)) _edges.Add(new Seg { ax = cx - half, az = cz - half, bx = cx - half, bz = cz + half }); + if (!HasTile(cx, cz + size, tol)) _edges.Add(new Seg { ax = cx - half, az = cz + half, bx = cx + half, bz = cz + half }); + if (!HasTile(cx, cz - size, tol)) _edges.Add(new Seg { ax = cx - half, az = cz - half, bx = cx + half, bz = cz - half }); + } + } + + bool HasTile(float x, float z, float tol) + { + for (int i = 0; i < _tiles.Count; i++) + if (Mathf.Abs(_tiles[i].x - x) <= tol && Mathf.Abs(_tiles[i].z - z) <= tol) return true; + return false; + } + + bool Inside(float x, float z, float half) + { + for (int i = 0; i < _tiles.Count; i++) + if (Mathf.Abs(x - _tiles[i].x) <= half && Mathf.Abs(z - _tiles[i].z) <= half) return true; + return false; + } + + /// 발자국 테두리까지의 부호 있는 거리(m). **음수 = 타일 안 · 양수 = 바깥(물 쪽)** · 테두리 선 위 = 0. + float SignedDist(float x, float z, float half) + { + float best = float.MaxValue; + for (int i = 0; i < _edges.Count; i++) + { + var s = _edges[i]; + float ex = s.bx - s.ax, ez = s.bz - s.az; + float t = Mathf.Clamp01(((x - s.ax) * ex + (z - s.az) * ez) / (ex * ex + ez * ez)); + float dx = x - (s.ax + ex * t), dz = z - (s.az + ez * t); + float d2 = dx * dx + dz * dz; + if (d2 < best) best = d2; + } + float d = Mathf.Sqrt(best); + return Inside(x, z, half) ? -d : d; + } + + // ───────────────────────────────────────────────────────────────── + // 메시 — 격자(셀 skirtCell) · 정점마다 d → 높이(smoothstep)·정점색 + // ───────────────────────────────────────────────────────────────── + float BuildMesh(float half, float topY, float waterY, Color grass, Color sand) + { + float cell = Mathf.Max(0.1f, cfg.skirtCell); + float width = Mathf.Max(cell, cfg.skirtWidth); + float keep = Mathf.Max(0f, cfg.skirtInnerKeep); + float top = topY - Mathf.Max(0f, cfg.skirtTopDrop); + float bottom = cfg.skirtBottomY; + float band = Mathf.Max(0f, cfg.skirtGrassBand); + float blend = Mathf.Max(0.01f, cfg.skirtSandBlend); + float uw = Mathf.Clamp01(cfg.skirtUnderwaterMul); + + float minX = float.MaxValue, minZ = float.MaxValue, maxX = float.MinValue, maxZ = float.MinValue; + for (int i = 0; i < _tiles.Count; i++) + { + var t = _tiles[i]; + if (t.x - half < minX) minX = t.x - half; + if (t.x + half > maxX) maxX = t.x + half; + if (t.z - half < minZ) minZ = t.z - half; + if (t.z + half > maxZ) maxZ = t.z + half; + } + + // 격자 정점 수 상한 — 넘으면 칸을 키운다(모바일 안전 · 섬이 아무리 커져도 비용이 묶인다) + int maxNodes = Mathf.Max(1000, cfg.skirtMaxGridNodes); + int mc, nx, nz; + while (true) + { + mc = Mathf.CeilToInt((width + cell * 2f) / cell); + nx = Mathf.CeilToInt((maxX - minX) / cell - 0.0001f) + 2 * mc + 1; + nz = Mathf.CeilToInt((maxZ - minZ) / cell - 0.0001f) + 2 * mc + 1; + if ((long)nx * nz <= maxNodes) break; + cell *= 2f; + } + // 원점 = 발자국 최소 가장자리에서 정수 칸만큼 바깥 → 칸이 타일 한 변을 나누면 정점이 모든 타일 가장자리 선 위에 놓인다 + float x0 = minX - mc * cell, z0 = minZ - mc * cell; + + var dist = new float[nx * nz]; + var index = new int[nx * nz]; + for (int j = 0; j < nz; j++) + { + float z = z0 + j * cell; + for (int i = 0; i < nx; i++) dist[j * nx + i] = SignedDist(x0 + i * cell, z, half); + } + + _verts.Clear(); _cols.Clear(); _tris.Clear(); + float outerKeep = width + cell * 2f; // 사각형 네 귀가 전부 있어야 하므로 바깥으로 두 칸 여유 + for (int j = 0; j < nz; j++) + { + for (int i = 0; i < nx; i++) + { + int n = j * nx + i; + float d = dist[n]; + if (d < -keep || d > outerKeep) { index[n] = -1; continue; } + + float t = Mathf.Clamp01(d / width); + t = t * t * (3f - 2f * t); // smoothstep — 윗면에서도 바닥에서도 접선이 수평(완만) + float y = top + (bottom - top) * t; + index[n] = _verts.Count; + _verts.Add(new Vector3(x0 + i * cell, y, z0 + j * cell)); + + float k = Mathf.Clamp01((d - band) / blend); // 풀 띠(0~band) → blend 폭에서 모래로 + k = k * k * (3f - 2f * k); + var c = Color.LerpUnclamped(grass, sand, k); + if (y < waterY) { c.r *= uw; c.g *= uw; c.b *= uw; } + c.a = 1f; + _cols.Add(c); + } + } + + for (int j = 0; j < nz - 1; j++) + { + for (int i = 0; i < nx - 1; i++) + { + int n = j * nx + i; + int ia = index[n], ib = index[n + 1], ic = index[n + nx], id = index[n + nx + 1]; + if (ia < 0 || ib < 0 || ic < 0 || id < 0) continue; + // 네 귀가 전부 경사 바깥(d ≥ width)이면 바닥과 같은 높이의 평판 — 만들지 않는다 + float dm = Mathf.Min(Mathf.Min(dist[n], dist[n + 1]), Mathf.Min(dist[n + nx], dist[n + nx + 1])); + if (dm >= width) continue; + // 바닥판(SpawnSeabed)과 같은 감김 — (x0,z0)→(x0,z1)→(x1,z1) 이 위를 향한다 + _tris.Add(ia); _tris.Add(ic); _tris.Add(id); + _tris.Add(ia); _tris.Add(id); _tris.Add(ib); + } + } + + _mesh.Clear(); + _mesh.indexFormat = _verts.Count > 65000 ? IndexFormat.UInt32 : IndexFormat.UInt16; + _mesh.SetVertices(_verts); + _mesh.SetColors(_cols); + _mesh.SetTriangles(_tris, 0); + _mesh.RecalculateNormals(); // DepthNormals 패스용 + _mesh.RecalculateBounds(); + + LastVertices = _verts.Count; + LastTriangles = _tris.Count / 3; + return cell; + } + } +} diff --git a/Assets/WL/Look/Farm/WLShoreSkirt.cs.meta b/Assets/WL/Look/Farm/WLShoreSkirt.cs.meta new file mode 100644 index 000000000..7f4490188 --- /dev/null +++ b/Assets/WL/Look/Farm/WLShoreSkirt.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00f6070a704765f47af4936dfb74e80d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: