From f9baacd8bd9310aa1df24a0cd246214f4f073ea6 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:44 +0300 Subject: [PATCH 1/9] feat: add WorldMeshWriteJob and WorldMeshChunkData for jobified mesh writes --- .../Meshes/Builder/WorldMeshChunkData.cs | 49 +++++++++++++ .../Meshes/Builder/WorldMeshChunkData.cs.meta | 2 + .../Meshes/Builder/WorldMeshWriteJob.cs | 70 +++++++++++++++++++ .../Meshes/Builder/WorldMeshWriteJob.cs.meta | 2 + 4 files changed, 123 insertions(+) create mode 100644 Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs create mode 100644 Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs.meta create mode 100644 Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs create mode 100644 Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs.meta diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs new file mode 100644 index 000000000..e0a6e7012 --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs @@ -0,0 +1,49 @@ +using System.Runtime.InteropServices; +using Unity.Mathematics; + +namespace Gothic.Core.Domain.Meshes.Builder +{ + /// + /// Interleaved vertex layout for opaque and transparent world chunks. + /// Must match the VertexAttributeDescriptors inside WorldMeshBuilder: + /// Position (Float32x3), Normal (SNorm16x4), Color (UNorm8x4), TexCoord0 (Float32x4). + /// + [StructLayout(LayoutKind.Sequential)] + public struct WorldChunkVertex + { + public float3 Position; + public short NormalX; + public short NormalY; + public short NormalZ; + public short NormalW; + public uint Color; + public float4 Uv; + } + + /// + /// Interleaved vertex layout for water chunks. The water shader uses no normals or vertex colors: + /// Position (Float32x3), TexCoord0 (Float32x4), TexCoord1 (Float32x4). + /// + [StructLayout(LayoutKind.Sequential)] + public struct WorldWaterVertex + { + public float3 Position; + public float4 Uv; + public float4 TexAnim; + } + + /// + /// Slice of the flat world extraction buffers which belongs to one chunk mesh. + /// + public struct WorldChunkMeshRange + { + public int VertexStart; + public int VertexCount; + public int IndexStart; + public int IndexCount; + public bool IsWater; + public bool Use16BitIndices; + public float3 BoundsMin; + public float3 BoundsMax; + } +} diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs.meta b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs.meta new file mode 100644 index 000000000..dbc1f695a --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshChunkData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4aa22c7e9132fea618c4b0573a873082 \ No newline at end of file diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs new file mode 100644 index 000000000..1eb8baf22 --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs @@ -0,0 +1,70 @@ +using Unity.Burst; +using Unity.Collections; +using Unity.Jobs; +using UnityEngine; +using UnityEngine.Rendering; + +namespace Gothic.Core.Domain.Meshes.Builder +{ + /// + /// Writes the extracted and deduplicated world chunk data into writable MeshData buffers. + /// One Execute() call per chunk mesh, Burst-compiled and running in parallel for all chunks at once. + /// Vertex and index buffer params must be set by the caller before scheduling. + /// + [BurstCompile] + public struct WorldMeshWriteJob : IJobParallelFor + { + [ReadOnly] public NativeArray Ranges; + [ReadOnly] public NativeArray Vertices; + [ReadOnly] public NativeArray WaterVertices; + [ReadOnly] public NativeArray Indices; + + public Mesh.MeshDataArray MeshData; + + public void Execute(int chunkIndex) + { + var range = Ranges[chunkIndex]; + var meshData = MeshData[chunkIndex]; + + if (range.IsWater) + { + var vertexBuffer = meshData.GetVertexData(); + NativeArray.Copy(WaterVertices, range.VertexStart, vertexBuffer, 0, range.VertexCount); + } + else + { + var vertexBuffer = meshData.GetVertexData(); + NativeArray.Copy(Vertices, range.VertexStart, vertexBuffer, 0, range.VertexCount); + } + + if (range.Use16BitIndices) + { + var indexBuffer = meshData.GetIndexData(); + for (var i = 0; i < range.IndexCount; i++) + { + indexBuffer[i] = (ushort)Indices[range.IndexStart + i]; + } + } + else + { + var indexBuffer = meshData.GetIndexData(); + for (var i = 0; i < range.IndexCount; i++) + { + indexBuffer[i] = (uint)Indices[range.IndexStart + i]; + } + } + + var bounds = new Bounds(); + bounds.SetMinMax(range.BoundsMin, range.BoundsMax); + + meshData.subMeshCount = 1; + meshData.SetSubMesh(0, + new SubMeshDescriptor(0, range.IndexCount) + { + vertexCount = range.VertexCount, + bounds = bounds + }, + MeshUpdateFlags.DontRecalculateBounds | MeshUpdateFlags.DontValidateIndices | MeshUpdateFlags.DontNotifyMeshUsers); + } + } +} diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs.meta b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs.meta new file mode 100644 index 000000000..759c37723 --- /dev/null +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshWriteJob.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d00e26ed337e68dc683d0c2ed196531d \ No newline at end of file From c5eed0435fb9fe4ac3add7072f19ae5609db64c4 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:44 +0300 Subject: [PATCH 2/9] perf: rebuild WorldMeshBuilder onto MeshDataArray write jobs Single GetPolygon sweep caches the expanded vertex count per chunk, writes go through Burst jobs into AllocateWritableMeshData, with JobHandle.Complete + dispose on the error path. --- .../Meshes/Builder/AbstractMeshBuilder.cs | 74 ++- .../Domain/Meshes/Builder/WorldMeshBuilder.cs | 536 +++++++++++++----- 2 files changed, 440 insertions(+), 170 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs index 95652b631..9c0fc5e6b 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs @@ -12,7 +12,6 @@ using MyBox; using Reflex.Attributes; using UnityEngine; -using UnityEngine.Rendering; using ZenKit; using Logger = Gothic.Core.Logging.Logger; using Material = UnityEngine.Material; @@ -117,7 +116,7 @@ public void SetMdm(string mdmName) if (Mdm == null) { - Logger.LogError($"MDH from name >{mdmName}< for object >{RootGo.name}< not found.", LogCat.Mesh); + Logger.LogError($"MDM from name >{mdmName}< for object >{RootGo.name}< not found.", LogCat.Mesh); } } @@ -142,7 +141,7 @@ public void SetMrm(string mrmName) if (Mrm == null) { - Logger.LogError($"MDH from name >{mrmName}< for object >{RootGo.name}< not found.", LogCat.Mesh); + Logger.LogError($"MRM from name >{mrmName}< for object >{RootGo.name}< not found.", LogCat.Mesh); } } @@ -392,7 +391,7 @@ protected void PrepareMeshRenderer(Renderer rend, IMultiResolutionMesh mrmData) if (materialData.Texture.IsEmpty()) // No texture to add. { Logger.LogWarning("No texture was set for: " + materialData.Name, LogCat.Mesh); - return; + continue; } Texture texture; @@ -498,33 +497,32 @@ protected void PrepareMeshFilter(MeshFilter meshFilter, IMultiResolutionMesh mrm preparedTriangles.Add(new List()); } - for (var i = 0; i < subMesh.Triangles.Count; i++) + // Determine which triangle list to use + var triangleList = UseTextureArray + ? preparedTriangles[subMeshPerTextureFormat[textureArrayType]] + : preparedTriangles[^1]; + + void AddWedgeVertex(MeshWedge wedge) { - // One triangle is made of 3 elements for Unity. We therefore need to prepare 3 elements within one loop. - MeshWedge[] wedges = - { - subMesh.Wedges[subMesh.Triangles[i].Wedge2], subMesh.Wedges[subMesh.Triangles[i].Wedge1], - subMesh.Wedges[subMesh.Triangles[i].Wedge0] - }; + var position = calculatedVertices[wedge.Index]; + preparedVertices.Add(new Vector3(position.X / 100f, position.Y / 100f, position.Z / 100f)); + normals.Add(new Vector3(wedge.Normal.X, wedge.Normal.Y, wedge.Normal.Z)); + preparedUVs.Add(new Vector4(wedge.Texture.X * textureScale.x, wedge.Texture.Y * textureScale.y, + textureArrayIndex, maxMipLevel)); + triangleList.Add(index++); + + CreateMorphMeshEntry(wedge.Index, preparedVertices.Count); + } - for (var w = 0; w < wedges.Length; w++) - { - preparedVertices.Add(calculatedVertices[wedges[w].Index].ToUnityVector()); - if (UseTextureArray) - { - preparedTriangles[subMeshPerTextureFormat[textureArrayType]].Add(index++); - } - else - { - preparedTriangles[preparedTriangles.Count - 1].Add(index++); - } - - normals.Add(wedges[w].Normal.ToUnityVector()); - var uv = Vector2.Scale(textureScale, wedges[w].Texture.ToUnityVector()); - preparedUVs.Add(new Vector4(uv.x, uv.y, textureArrayIndex, maxMipLevel)); - - CreateMorphMeshEntry(wedges[w].Index, preparedVertices.Count); - } + var wedges = subMesh.Wedges; + var triangles = subMesh.Triangles; + for (var ti = 0; ti < triangles.Count; ti++) + { + var triangle = triangles[ti]; + // The wedge order is reversed to flip the triangle winding for Unity's coordinate system. + AddWedgeVertex(wedges[triangle.Wedge2]); + AddWedgeVertex(wedges[triangle.Wedge1]); + AddWedgeVertex(wedges[triangle.Wedge0]); } } @@ -736,22 +734,18 @@ private Material GetDefaultMaterial(TextureCacheService.TextureArrayTypes textur { if (UseTextureArray) { - Shader shader; switch (textureType) { case TextureCacheService.TextureArrayTypes.Opaque: - shader = Constants.ShaderWorldLit; - break; + return new Material(Constants.ShaderWorldLit); case TextureCacheService.TextureArrayTypes.Transparent: // Cutout for e.g. bushes. - shader = Constants.ShaderLitAlphaToCoverage; - break; + return new Material(Constants.ShaderLitAlphaToCoverage); + case TextureCacheService.TextureArrayTypes.Water: + return GetWaterMaterial(); default: throw new ArgumentOutOfRangeException(nameof(textureType), textureType, null); } - - var material = new Material(shader); - return material; } else { @@ -761,10 +755,8 @@ private Material GetDefaultMaterial(TextureCacheService.TextureArrayTypes textur protected Material GetWaterMaterial() { - var material = new Material(Constants.ShaderWater); - // Manually correct the render queue for alpha test, as Unity doesn't want to do it from the shader's render queue tag. - material.renderQueue = (int)RenderQueue.Transparent; - return material; + // The render queue is defined by the water shader's "Queue" tag. + return new Material(Constants.ShaderWater); } protected void SetPosAndRot(GameObject obj, Matrix4x4 matrix) diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshBuilder.cs b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshBuilder.cs index 4ee7aeb7b..9bf6d42e8 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshBuilder.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/WorldMeshBuilder.cs @@ -2,42 +2,102 @@ using System.Collections.Generic; using System.Threading.Tasks; using Gothic.Core.Adapters.UI.LoadingBars; -using Gothic.Core.Const; using Gothic.Core.Domain.StaticCache; +using Gothic.Core.Extensions; +using Gothic.Core.Const; using Gothic.Core.Manager; using Gothic.Core.Services; using Gothic.Core.Services.Caches; -using Gothic.Core.Services.Config; using Gothic.Core.Services.StaticCache; -using Gothic.Core.Extensions; using JetBrains.Annotations; using Reflex.Attributes; -using UnityEditor; using UnityEngine; using UnityEngine.Rendering; +using Unity.Collections; +using Unity.Jobs; +using Unity.Mathematics; using ZenKit; using Mesh = UnityEngine.Mesh; using Material = UnityEngine.Material; namespace Gothic.Core.Domain.Meshes.Builder { + /// + /// Builds all world chunk meshes via the writable MeshData API: + /// 1. Gather per-material texture array data on the main thread (requires DI services). + /// 2. Extract and deduplicate all chunk vertices from the cached ZenKit mesh on a background thread. + /// 3. One Burst job writes the vertex/index buffers of all chunks in parallel. + /// 4. Apply all meshes at once, then create GameObjects and colliders spread over frames. + /// public class WorldMeshBuilder : AbstractMeshBuilder { - [Inject] private readonly ConfigService _configService; [Inject] private readonly FrameSkipperService _frameSkipperService; private StaticCacheService.WorldChunkContainer _worldChunks; private IMesh _mesh; - private bool _debugSpeedUpLoading; - public class ChunkData + private static readonly VertexAttributeDescriptor[] _worldVertexLayout = + { + new(VertexAttribute.Position, VertexAttributeFormat.Float32, 3), + new(VertexAttribute.Normal, VertexAttributeFormat.SNorm16, 4), + new(VertexAttribute.Color, VertexAttributeFormat.UNorm8, 4), + new(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 4) + }; + + private static readonly VertexAttributeDescriptor[] _waterVertexLayout = + { + new(VertexAttribute.Position, VertexAttributeFormat.Float32, 3), + new(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 4), + new(VertexAttribute.TexCoord1, VertexAttributeFormat.Float32, 4) + }; + + /// + /// Per-material data baked into the vertices. Gathered once on the main thread. + /// + private struct MaterialEntry + { + public int TextureArrayIndex; + public float2 TextureScale; + public int MaxMipLevel; + public float4 TexAnim; + } + + private class ChunkBuildData { - public readonly List Vertices = new(); - public readonly List Triangles = new(); - public readonly List Uvs = new(); - public readonly List Normals = new(); - public readonly List BakedLightColors = new(); - public readonly List TextureAnimations = new(); + public TextureCacheService.TextureArrayTypes Type; + public WorldChunkCacheCreatorDomain.WorldChunk Chunk; + + // Expanded (pre-deduplication, worst-case) vertex count, cached during the GetPolygon sweep in + // GatherMaterialEntries so ExtractChunks can size its buffers without a second native sweep. + public int ExpandedVertexCount; + } + + private struct ExtractionBuffers : IDisposable + { + public NativeArray Ranges; + public NativeArray Vertices; + public NativeArray WaterVertices; + public NativeArray Indices; + + public void Dispose() + { + if (Ranges.IsCreated) + { + Ranges.Dispose(); + } + if (Vertices.IsCreated) + { + Vertices.Dispose(); + } + if (WaterVertices.IsCreated) + { + WaterVertices.Dispose(); + } + if (Indices.IsCreated) + { + Indices.Dispose(); + } + } } public void SetWorldData(StaticCacheService.WorldChunkContainer worldChunks, IMesh mesh) @@ -53,50 +113,91 @@ public override GameObject Build() public async Task BuildAsync([CanBeNull] LoadingService loading) { - _debugSpeedUpLoading = _configService.Dev.SpeedUpLoading; - RootGo.isStatic = true; - var chunksCount = _worldChunks.OpaqueChunks.Count + _worldChunks.TransparentChunks.Count + _worldChunks.WaterChunks.Count; - - loading?.SetPhase(nameof(WorldLoadingBarHandler.ProgressType.WorldMesh), chunksCount); + var chunks = CollectChunks(out var emptyChunkCount); - await BuildChunkType(_worldChunks.OpaqueChunks, Services.Caches.TextureCacheService.TextureArrayTypes.Opaque, loading); - await BuildChunkType(_worldChunks.TransparentChunks, Services.Caches.TextureCacheService.TextureArrayTypes.Transparent, loading); - await BuildChunkType(_worldChunks.WaterChunks, Services.Caches.TextureCacheService.TextureArrayTypes.Water, loading); + loading?.SetPhase(nameof(WorldLoadingBarHandler.ProgressType.WorldMesh), chunks.Count + emptyChunkCount); + for (var i = 0; i < emptyChunkCount; i++) + { + loading?.Tick(); + } + + if (chunks.Count == 0) + { + return; + } + + var materials = GatherMaterialEntries(chunks); + + // Heavy ZenKit data extraction incl. vertex deduplication runs on a background thread. + // The cached world mesh is only read here, which is thread-safe. + var buffers = await Task.Run(() => ExtractChunks(chunks, materials)); + try + { + var meshes = await BuildChunkMeshes(chunks.Count, buffers); + await CreateChunkGameObjects(chunks, buffers, meshes, loading); + } + finally + { + buffers.Dispose(); + } } - private async Task BuildChunkType(List chunks, TextureCacheService.TextureArrayTypes type, [CanBeNull] LoadingService loading) + private List CollectChunks(out int emptyChunkCount) { - var chunkTypeRoot = new GameObject - { - name = type.ToString(), - isStatic = true - }; - chunkTypeRoot.SetParent(RootGo); + var chunks = new List(); + var emptyCount = 0; - var loopIndex = 0; - foreach (var chunk in chunks) + void Collect(List typeChunks, TextureCacheService.TextureArrayTypes type) { - var chunkGo = new GameObject + foreach (var chunk in typeChunks) { - name = $"{type}-Entry-{loopIndex++}", - isStatic = true, - layer = type == Services.Caches.TextureCacheService.TextureArrayTypes.Water ? Constants.WaterLayer : Constants.DefaultLayer, - }; - chunkGo.SetParent(chunkTypeRoot); + if (chunk.PolygonIds.Count == 0) + { + emptyCount++; + } + else + { + chunks.Add(new ChunkBuildData { Type = type, Chunk = chunk }); + } + } + } + + Collect(_worldChunks.OpaqueChunks, TextureCacheService.TextureArrayTypes.Opaque); + Collect(_worldChunks.TransparentChunks, TextureCacheService.TextureArrayTypes.Transparent); + Collect(_worldChunks.WaterChunks, TextureCacheService.TextureArrayTypes.Water); + + emptyChunkCount = emptyCount; + return chunks; + } - var chunkData = new ChunkData(); - foreach (var polygonId in chunk.PolygonIds) + private MaterialEntry[] GatherMaterialEntries(List chunks) + { + var entries = new MaterialEntry[_mesh.MaterialCount]; + var gathered = new bool[_mesh.MaterialCount]; + + foreach (var data in chunks) + { + var expandedVertexCount = 0; + foreach (var polygonId in data.Chunk.PolygonIds) { var polygon = _mesh.GetPolygon(polygonId); - var material = _mesh.GetMaterial(polygon.MaterialIndex); + expandedVertexCount += Math.Max(0, (polygon.PositionIndices.Count - 2) * 3); - // Defaults which will be used, if we don't use TextureArray (e.g. for OC baking and with debug textures only) - int textureArrayIndex = -1; - Vector2 textureScale = Vector2.one; - int maxMipLevel = 0; - int animFrameCount = -1; + var materialIndex = polygon.MaterialIndex; + if (gathered[materialIndex]) + { + continue; + } + gathered[materialIndex] = true; + + var material = _mesh.GetMaterial(materialIndex); + + var textureArrayIndex = -1; + var textureScale = Vector2.one; + var maxMipLevel = 0; + var animFrameCount = 0; if (UseTextureArray) { @@ -104,144 +205,321 @@ private async Task BuildChunkType(List out textureScale, out maxMipLevel, out animFrameCount); } - // As we always use element 0 and i+1, we skip it in the loop. - // Positions are a triangle fan. i.e. every position after 0 leads back to position 0. - for (var p = 1; p < polygon.PositionIndices.Count - 1; p++) - { - AddPolygonChunkEntry(polygon, chunkData, material, 0, textureArrayIndex, textureScale, maxMipLevel, animFrameCount); - AddPolygonChunkEntry(polygon, chunkData, material, p, textureArrayIndex, textureScale, maxMipLevel, animFrameCount); - AddPolygonChunkEntry(polygon, chunkData, material, p+1, textureArrayIndex, textureScale, maxMipLevel, animFrameCount); - } + var animDirection = material.TextureAnimationMapping == AnimationMapping.Linear + ? material.TextureAnimationMappingDirection.ToUnityVector() + : Vector2.zero; - if (!_debugSpeedUpLoading) + entries[materialIndex] = new MaterialEntry { - // If we have the skips here, we have a smoother loading screen for 20 seconds on loading world. Putting it at the end of each chunk, we have stutter, but save about 40%. - await _frameSkipperService.TrySkipToNextFrame(); - } + TextureArrayIndex = textureArrayIndex, + TextureScale = new float2(textureScale.x, textureScale.y), + MaxMipLevel = maxMipLevel, + TexAnim = new float4(animDirection.x, animDirection.y, animFrameCount + 1, + material.TextureAnimationFps) + }; } - var meshFilter = chunkGo.AddComponent(); - var meshRenderer = chunkGo.AddComponent(); + data.ExpandedVertexCount = expandedVertexCount; + } - PrepareMeshFilter(meshFilter, chunkData, type); - PrepareMeshRenderer(meshRenderer, type); - PrepareMeshCollider(chunkGo, meshFilter.sharedMesh); + return entries; + } + private ExtractionBuffers ExtractChunks(List chunks, MaterialEntry[] materials) + { + // Pass 1 - size the buffers from the expanded vertex counts cached by GatherMaterialEntries + // (worst case, pre-deduplication). No second GetPolygon sweep needed here. + var worldVertexCapacity = 0; + var waterVertexCapacity = 0; + var indexCapacity = 0; + foreach (var data in chunks) + { + var expanded = data.ExpandedVertexCount; -#if UNITY_EDITOR - // Only needed for Occlusion Culling baking - // Don't set transparent meshes as occluders. - if (IsTransparentShader(type)) + if (data.Type == TextureCacheService.TextureArrayTypes.Water) { - GameObjectUtility.SetStaticEditorFlags(chunkGo, - (StaticEditorFlags)(int.MaxValue & ~(int)StaticEditorFlags.OccluderStatic)); + waterVertexCapacity += expanded; } -#endif + else + { + worldVertexCapacity += expanded; + } + indexCapacity += expanded; + } + var buffers = new ExtractionBuffers + { + Ranges = new NativeArray(chunks.Count, Allocator.Persistent, + NativeArrayOptions.UninitializedMemory), + Vertices = new NativeArray(Math.Max(1, worldVertexCapacity), Allocator.Persistent, + NativeArrayOptions.UninitializedMemory), + WaterVertices = new NativeArray(Math.Max(1, waterVertexCapacity), Allocator.Persistent, + NativeArrayOptions.UninitializedMemory), + Indices = new NativeArray(Math.Max(1, indexCapacity), Allocator.Persistent, + NativeArrayOptions.UninitializedMemory) + }; - loading?.Tick(); + // Pass 2 - flatten the polygons into per-chunk vertex/index ranges with deduplication. + // Vertices are unique per position + feature + material combination. + var worldVertexCursor = 0; + var waterVertexCursor = 0; + var indexCursor = 0; + var deduplication = new Dictionary<(int Position, int Feature, int Material), int>(); + + for (var chunkIndex = 0; chunkIndex < chunks.Count; chunkIndex++) + { + var data = chunks[chunkIndex]; + var isWater = data.Type == TextureCacheService.TextureArrayTypes.Water; + var vertexStart = isWater ? waterVertexCursor : worldVertexCursor; + var indexStart = indexCursor; + var vertexCount = 0; + var boundsMin = new float3(float.MaxValue); + var boundsMax = new float3(float.MinValue); + deduplication.Clear(); + + foreach (var polygonId in data.Chunk.PolygonIds) + { + var polygon = _mesh.GetPolygon(polygonId); + var positionIndices = polygon.PositionIndices; + var featureIndices = polygon.FeatureIndices; + var materialIndex = polygon.MaterialIndex; + var material = materials[materialIndex]; + + void AddCorner(int fanIndex) + { + var key = (positionIndices[fanIndex], featureIndices[fanIndex], materialIndex); + if (!deduplication.TryGetValue(key, out var localIndex)) + { + localIndex = vertexCount++; + deduplication[key] = localIndex; + + var position = _mesh.GetPosition(key.Item1); + var feature = _mesh.GetFeature(key.Item2); + var unityPosition = new float3(position.X, position.Y, position.Z) / 100f; + boundsMin = math.min(boundsMin, unityPosition); + boundsMax = math.max(boundsMax, unityPosition); + + var uv = new float4( + feature.Texture.X * material.TextureScale.x, + feature.Texture.Y * material.TextureScale.y, + material.TextureArrayIndex, + material.MaxMipLevel); + + if (isWater) + { + buffers.WaterVertices[vertexStart + localIndex] = new WorldWaterVertex + { + Position = unityPosition, + Uv = uv, + TexAnim = material.TexAnim + }; + } + else + { + var light = (uint)feature.Light; + buffers.Vertices[vertexStart + localIndex] = new WorldChunkVertex + { + Position = unityPosition, + NormalX = ToSnorm16(feature.Normal.X), + NormalY = ToSnorm16(feature.Normal.Y), + NormalZ = ToSnorm16(feature.Normal.Z), + NormalW = 0, + // Gothic's baked vertex light, kept untouched: ARGB int -> RGBA byte order. + Color = ((light >> 16) & 0xFF) | (((light >> 8) & 0xFF) << 8) | + ((light & 0xFF) << 16) | (((light >> 24) & 0xFF) << 24), + Uv = uv + }; + } + } + + buffers.Indices[indexCursor++] = localIndex; + } + + for (var p = 1; p < positionIndices.Count - 1; p++) + { + // Fan triangulation. Second and third corner are swapped to flip the + // triangle winding for Unity's coordinate system (Gothic -> Unity fix). + AddCorner(0); + AddCorner(p + 1); + AddCorner(p); + } + } + + if (isWater) + { + waterVertexCursor += vertexCount; + } + else + { + worldVertexCursor += vertexCount; + } + + buffers.Ranges[chunkIndex] = new WorldChunkMeshRange + { + VertexStart = vertexStart, + VertexCount = vertexCount, + IndexStart = indexStart, + IndexCount = indexCursor - indexStart, + IsWater = isWater, + Use16BitIndices = vertexCount <= ushort.MaxValue, + BoundsMin = boundsMin, + BoundsMax = boundsMax + }; } + + return buffers; } - private void AddPolygonChunkEntry(IPolygon polygon, ChunkData chunkData, IMaterial material, int index, - int textureArrayIndex, Vector2 scaleInTextureArray, int maxMipLevel = 16, int animFrameCount = 0) + private async Task BuildChunkMeshes(int chunkCount, ExtractionBuffers buffers) { - // For every vertexIndex we store a new vertex. (i.e. no reuse of Vector3-vertices for later texture/uv attachment) - var positionIndex = polygon.PositionIndices[index]; - chunkData.Vertices.Add(_mesh.GetPosition(positionIndex).ToUnityVector()); - - // This triangle (index where Vector 3 lies inside vertices, points to the newly added vertex (Vector3) as we don't reuse vertices. - chunkData.Triangles.Add(chunkData.Vertices.Count - 1); + var meshDataArray = Mesh.AllocateWritableMeshData(chunkCount); + var jobHandle = default(JobHandle); + try + { + for (var i = 0; i < chunkCount; i++) + { + var range = buffers.Ranges[i]; + var meshData = meshDataArray[i]; + meshData.SetVertexBufferParams(range.VertexCount, range.IsWater ? _waterVertexLayout : _worldVertexLayout); + meshData.SetIndexBufferParams(range.IndexCount, range.Use16BitIndices ? IndexFormat.UInt16 : IndexFormat.UInt32); + } - var featureIndex = polygon.FeatureIndices[index]; - var feature = _mesh.GetFeature(featureIndex); - var uv = Vector2.Scale(scaleInTextureArray, feature.Texture.ToUnityVector()); - chunkData.Uvs.Add(new Vector4(uv.x, uv.y, textureArrayIndex, maxMipLevel)); - chunkData.Normals.Add(feature.Normal.ToUnityVector()); - chunkData.BakedLightColors.Add(new Color32((byte)(feature.Light >> 16), (byte)(feature.Light >> 8), (byte)feature.Light, (byte)(feature.Light >> 24))); + var writeJob = new WorldMeshWriteJob + { + Ranges = buffers.Ranges, + Vertices = buffers.Vertices, + WaterVertices = buffers.WaterVertices, + Indices = buffers.Indices, + MeshData = meshDataArray + }; + jobHandle = writeJob.Schedule(chunkCount, 1); + JobHandle.ScheduleBatchedJobs(); - // HINT: We set animFrameCount + 1 as internally, Water.shader is leveraging a % animFrameCountValue. - // No animation -> % 1 is always 0, which means there is always texture 0 used. - // 1 Animation -> % 2 (animFrameCount(1) + 1 = 2) - is switching between both values. - if (material.TextureAnimationMapping == AnimationMapping.Linear) + // Let the Burst workers fill the mesh buffers without blocking the main thread. + while (!jobHandle.IsCompleted) + { + await Task.Yield(); + } + jobHandle.Complete(); + } + catch { - var uvAnimation = material.TextureAnimationMappingDirection.ToUnityVector(); - chunkData.TextureAnimations.Add(new Vector4(uvAnimation.x, uvAnimation.y, animFrameCount + 1, material.TextureAnimationFps)); + // The scheduled job still owns meshDataArray; finish it before disposing the native memory, + // otherwise (e.g. on a cancellation thrown inside the await loop) we'd dispose buffers in use. + jobHandle.Complete(); + meshDataArray.Dispose(); + throw; } - else + + var meshes = new Mesh[chunkCount]; + for (var i = 0; i < chunkCount; i++) { - chunkData.TextureAnimations.Add(new Vector4(0, 0, animFrameCount + 1, material.TextureAnimationFps)); + meshes[i] = new Mesh(); } + + Mesh.ApplyAndDisposeWritableMeshData(meshDataArray, meshes, + MeshUpdateFlags.DontRecalculateBounds | MeshUpdateFlags.DontValidateIndices | MeshUpdateFlags.DontNotifyMeshUsers); + + return meshes; } - private void PrepareMeshFilter(MeshFilter meshFilter, ChunkData chunk, TextureCacheService.TextureArrayTypes textureArrayType) + private async Task CreateChunkGameObjects(List chunks, ExtractionBuffers buffers, Mesh[] meshes, + [CanBeNull] LoadingService loading) { - // We need to reverse all data. Otherwise, meshes are visible upside down. It's a difference from rendering ZenGine data in Unity. - // Hint: No, Triangles mustn't be reversed. Only applied data on it. - chunk.BakedLightColors.Reverse(); - chunk.Normals.Reverse(); - chunk.TextureAnimations.Reverse(); - chunk.Uvs.Reverse(); - chunk.Vertices.Reverse(); - - var mesh = new Mesh(); - meshFilter.sharedMesh = mesh; - mesh.SetVertices(chunk.Vertices); - mesh.SetTriangles(chunk.Triangles, 0); - mesh.SetUVs(0, chunk.Uvs); - mesh.SetNormals(chunk.Normals); - mesh.SetColors(chunk.BakedLightColors); + var typeRoots = new Dictionary(); + var typeCounters = new Dictionary(); + foreach (var type in new[] + { + TextureCacheService.TextureArrayTypes.Opaque, + TextureCacheService.TextureArrayTypes.Transparent, + TextureCacheService.TextureArrayTypes.Water + }) + { + var typeRoot = new GameObject + { + name = type.ToString(), + isStatic = true + }; + typeRoot.SetParent(RootGo); + typeRoots[type] = typeRoot; + typeCounters[type] = 0; + } - if (textureArrayType == Services.Caches.TextureCacheService.TextureArrayTypes.Water) + for (var i = 0; i < chunks.Count; i++) { - mesh.SetUVs(1, chunk.TextureAnimations); + await _frameSkipperService.TrySkipToNextFrame(); + + var data = chunks[i]; + var range = buffers.Ranges[i]; + var mesh = meshes[i]; + + var chunkGo = new GameObject + { + name = $"{data.Type}-Entry-{typeCounters[data.Type]++}", + isStatic = true, + layer = data.Type == TextureCacheService.TextureArrayTypes.Water + ? Constants.WaterLayer + : Constants.DefaultLayer + }; + chunkGo.SetParent(typeRoots[data.Type]); + + mesh.name = chunkGo.name; + var bounds = new Bounds(); + bounds.SetMinMax(range.BoundsMin, range.BoundsMax); + mesh.bounds = bounds; + + var meshFilter = chunkGo.AddComponent(); + var meshRenderer = chunkGo.AddComponent(); + meshFilter.sharedMesh = mesh; + + PrepareMeshRenderer(meshRenderer, data.Type); + PrepareMeshCollider(chunkGo, mesh); + +#if UNITY_EDITOR + if (data.Type != TextureCacheService.TextureArrayTypes.Opaque) + { + UnityEditor.GameObjectUtility.SetStaticEditorFlags(chunkGo, + (UnityEditor.StaticEditorFlags)(int.MaxValue & ~(int)UnityEditor.StaticEditorFlags.OccluderStatic)); + } +#endif + + loading?.Tick(); } } private void PrepareMeshRenderer(Renderer rend, TextureCacheService.TextureArrayTypes textureArrayType) { - - if (UseTextureArray) { var material = GetDefaultMaterial(textureArrayType); rend.material = material; - var texture = TextureCacheService.GetTextureArrayEntry(textureArrayType); - material.mainTexture = texture; + material.mainTexture = TextureCacheService.GetTextureArrayEntry(textureArrayType); } else { var material = new Material(Shader.Find("Universal Render Pipeline/Unlit")); rend.material = material; - // No TextureArray is only needed for Occlusion Culling in Editor mode and other dev tools. A dev texture is sufficient. material.mainTexture = Constants.TextureGothicUnityLogoInverse; } } - private Material GetDefaultMaterial(TextureCacheService.TextureArrayTypes textureArrayType) + private static Material GetDefaultMaterial(TextureCacheService.TextureArrayTypes textureArrayType) { var shader = textureArrayType switch { - Services.Caches.TextureCacheService.TextureArrayTypes.Opaque => Constants.ShaderWorldLit, - Services.Caches.TextureCacheService.TextureArrayTypes.Transparent => Constants.ShaderLitAlphaToCoverage, - Services.Caches.TextureCacheService.TextureArrayTypes.Water => Constants.ShaderWater, + TextureCacheService.TextureArrayTypes.Opaque => Constants.ShaderWorldLit, + TextureCacheService.TextureArrayTypes.Transparent => Constants.ShaderLitAlphaToCoverage, + TextureCacheService.TextureArrayTypes.Water => Constants.ShaderWater, _ => throw new ArgumentOutOfRangeException(nameof(textureArrayType), textureArrayType, null) }; - var material = new Material(shader); - - if (textureArrayType == Services.Caches.TextureCacheService.TextureArrayTypes.Water) - { - // Manually correct the render queue for alpha test, as Unity doesn't want to do it from the shader's render queue tag. - // If we don't set it, the water will sometimes "flicker" above ground or becomes invisible. - material.renderQueue = (int)RenderQueue.Transparent; - } - return material; + // Render queues are defined by the shaders' "Queue" tags. + return new Material(shader); } - private bool IsTransparentShader(TextureCacheService.TextureArrayTypes textureArrayType) + private static short ToSnorm16(float value) { - return textureArrayType != Services.Caches.TextureCacheService.TextureArrayTypes.Opaque; + return (short)math.round(math.clamp(value, -1f, 1f) * short.MaxValue); } } } From 169bd6e28d2a5aeb8bb90f3a29b5fd00738cfd8e Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:44 +0300 Subject: [PATCH 3/9] feat: texture-aware world-chunk slicing and cache version bump CalculateWorldChunks now consumes the cached TextureArray info; StaticCacheVersion bumped 4 -> 5 for the new chunk format. --- .../Adapters/Scenes/PreCachingScene.cs | 3 +- .../WorldChunkCacheCreatorDomain.cs | 61 ++++++++++++++----- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Adapters/Scenes/PreCachingScene.cs b/Assets/Gothic-Core/Scripts/Adapters/Scenes/PreCachingScene.cs index 4ed49a902..fa71a655c 100644 --- a/Assets/Gothic-Core/Scripts/Adapters/Scenes/PreCachingScene.cs +++ b/Assets/Gothic-Core/Scripts/Adapters/Scenes/PreCachingScene.cs @@ -150,7 +150,8 @@ private async Task CreateCaches() await stationaryLightCache.CalculateStationaryLights(world.RootObjects, worldIndex); watch.LogAndRestart($"{worldName}: Stationary lights calculated."); - await worldChunkCache.CalculateWorldChunks(world, stationaryLightCache.StationaryLightBounds, worldIndex); + await worldChunkCache.CalculateWorldChunks(world, stationaryLightCache.StationaryLightBounds, + worldIndex, textureArrayCache.TextureArrayInformation); watch.LogAndRestart($"{worldName}: World chunks calculated."); await _staticCacheService.SaveWorldCache(worldName, worldChunkCache.MergedChunksByLights, stationaryLightCache.StationaryLightInfos); diff --git a/Assets/Gothic-Core/Scripts/Domain/StaticCache/WorldChunkCacheCreatorDomain.cs b/Assets/Gothic-Core/Scripts/Domain/StaticCache/WorldChunkCacheCreatorDomain.cs index 2c29f4d0a..33bf21efd 100644 --- a/Assets/Gothic-Core/Scripts/Domain/StaticCache/WorldChunkCacheCreatorDomain.cs +++ b/Assets/Gothic-Core/Scripts/Domain/StaticCache/WorldChunkCacheCreatorDomain.cs @@ -9,12 +9,12 @@ using Gothic.Core.Services; using Gothic.Core.Services.Caches; using Gothic.Core.Services.Config; +using Gothic.Core.Services.StaticCache; using MyBox; using Reflex.Attributes; using UnityEngine; using ZenKit; using Logger = Gothic.Core.Logging.Logger; -using TextureFormat = ZenKit.TextureFormat; namespace Gothic.Core.Domain.StaticCache { @@ -29,7 +29,6 @@ public class WorldChunk [Inject] private readonly ConfigService _configService; [Inject] private readonly FrameSkipperService _frameSkipperService; [Inject] private readonly LoadingService _loadingService; - [Inject] private readonly ResourceCacheService _resourceCacheService; public Dictionary> MergedChunksByLights; @@ -44,9 +43,10 @@ public class WorldChunk private const int _maxAmountOfPolygonsPerChunk = 10000; private List _stationaryLightBounds; + private Dictionary> _textureArrayInformation; - public async Task CalculateWorldChunks(IWorld world, List stationaryLightBounds, int worldIndex) + public async Task CalculateWorldChunks(IWorld world, List stationaryLightBounds, int worldIndex, Dictionary> textureArrayInformation) { var cachedBspTree = (CachedBspTree)world.BspTree.Cache(); @@ -54,6 +54,7 @@ public async Task CalculateWorldChunks(IWorld world, List stationaryLigh _loadingService.SetPhase($"{nameof(PreCachingLoadingBarHandler.ProgressTypesPerWorld.CalculateWorldChunks)}_{worldIndex}", elementAmount); _stationaryLightBounds = stationaryLightBounds; + _textureArrayInformation = textureArrayInformation; // Hint: We need to cache BspTree, otherwise looping through it will take ages. await BuildBspTree(world.Mesh, cachedBspTree); @@ -177,14 +178,13 @@ private async Task MergeWorldChunksByLightCount(IMesh mesh, CachedBspTree bspTre { var polygon = mesh.GetPolygon(polygonId); var material = mesh.GetMaterial(polygon.MaterialIndex); - var texture = _resourceCacheService.TryGetTexture(material.Texture); - if (texture == null) - { - continue; - } + // Polygons are routed into the chunk type their material will sample at runtime. This + // guarantees that the slice index baked into the vertices (uv.z) always belongs to the + // texture array which is bound to the chunk's renderer. + var textureArrayType = GetTextureArrayType(material); - if (material.Group == MaterialGroup.Water) + if (textureArrayType == TextureCacheService.TextureArrayTypes.Water) { // First time we have a Polygon with Water in this node. if (!isCurrentNodeWithWater) @@ -194,7 +194,7 @@ private async Task MergeWorldChunksByLightCount(IMesh mesh, CachedBspTree bspTre } currentWaterChunkPolygons.PolygonIds.Add(polygonId); } - else if (texture.Format == TextureFormat.Dxt1) + else if (textureArrayType == TextureCacheService.TextureArrayTypes.Opaque) { // First time we have a Polygon with Dxt1 in this node. if (!isCurrentNodeWithOpaque) @@ -204,8 +204,7 @@ private async Task MergeWorldChunksByLightCount(IMesh mesh, CachedBspTree bspTre } currentOpaqueChunkPolygons.PolygonIds.Add(polygonId); } - // aka TextureFormat.R8G8B8A8 or anything else (e.g. DTX3, which will be changed to uncompressed R8G8B8A8 anyways) - else + else if (textureArrayType == TextureCacheService.TextureArrayTypes.Transparent) { // First time we have a Polygon with R8G8B8A8 in this node. if (!isCurrentNodeWithTransparent) @@ -245,7 +244,7 @@ private async Task MergeWorldChunksByLightCount(IMesh mesh, CachedBspTree bspTre } if (currentTransparentChunkLightsCount > Constants.MaxLightsPerWorldChunk || currentTransparentChunkPolygons.PolygonIds.Count > _maxAmountOfPolygonsPerChunk) { - if (currentWaterChunkPolygons.PolygonIds.Count > _maxAmountOfPolygonsPerChunk) + if (currentTransparentChunkPolygons.PolygonIds.Count > _maxAmountOfPolygonsPerChunk) Logger.Log($"Polygon threshold of {_maxAmountOfPolygonsPerChunk} reached. Slicing Transparent chunk for world {mesh.Name} now.", LogCat.PreCaching); if (currentTransparentChunkPolygons.PolygonIds.NotNullOrEmpty()) @@ -277,12 +276,46 @@ private async Task MergeWorldChunksByLightCount(IMesh mesh, CachedBspTree bspTre MergedChunksByLights = new() { - { TextureCacheService.TextureArrayTypes.Opaque, new List(finalPolygonsOpaque) }, + { TextureCacheService.TextureArrayTypes.Opaque, finalPolygonsOpaque }, { TextureCacheService.TextureArrayTypes.Transparent, finalPolygonsTransparent }, { TextureCacheService.TextureArrayTypes.Water, finalPolygonsWater } }; } + /// + /// Mirrors the registration in TextureArrayCacheCreatorDomain and the runtime lookup in + /// TextureCacheService.GetTextureArrayIndex: a texture can live in the Water array AND a solid array + /// at the same time (dual-use, e.g. G1's OWODWAT_A0 on rivers and on the Old Camp cauldron), so the + /// material's group - not the texture - decides between Water and the solid arrays. + /// Textures which couldn't be loaded at precache time aren't registered; their polygons return + /// Unknown and are skipped. + /// + private TextureCacheService.TextureArrayTypes GetTextureArrayType(IMaterial material) + { + if (material.Group == MaterialGroup.Water + && _textureArrayInformation[TextureCacheService.TextureArrayTypes.Water].ContainsKey(material.Texture)) + { + return TextureCacheService.TextureArrayTypes.Water; + } + + if (_textureArrayInformation[TextureCacheService.TextureArrayTypes.Opaque].ContainsKey(material.Texture)) + { + return TextureCacheService.TextureArrayTypes.Opaque; + } + + if (_textureArrayInformation[TextureCacheService.TextureArrayTypes.Transparent].ContainsKey(material.Texture)) + { + return TextureCacheService.TextureArrayTypes.Transparent; + } + + if (_textureArrayInformation[TextureCacheService.TextureArrayTypes.Water].ContainsKey(material.Texture)) + { + return TextureCacheService.TextureArrayTypes.Water; + } + + return TextureCacheService.TextureArrayTypes.Unknown; + } + private int GetLightsInBound(Bounds nodeBounds, HashSet excludeElements) { var returnCount = 0; From 0db9e6fe569d2d7a66c34e94f1664a7557d6549b Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sat, 13 Jun 2026 13:03:44 +0300 Subject: [PATCH 4/9] feat: update lit shaders for the rebuilt world mesh --- Assets/Gothic-Core/Shaders/Barrier.shader | 3 +- .../Lit-AlphaToCoverage-Dynamic.shader | 14 +++---- .../Shaders/Lit-AlphaToCoverage.shader | 14 +++---- Assets/Gothic-Core/Shaders/Lit-Misc.shader | 27 +------------ .../Shaders/Lit-SingleMesh-Dynamic.shader | 9 +++-- .../Gothic-Core/Shaders/Lit-SingleMesh.shader | 9 +++-- Assets/Gothic-Core/Shaders/Lit-Water.shader | 40 ++++++------------- Assets/Gothic-Core/Shaders/Lit-World.shader | 13 +++--- 8 files changed, 48 insertions(+), 81 deletions(-) diff --git a/Assets/Gothic-Core/Shaders/Barrier.shader b/Assets/Gothic-Core/Shaders/Barrier.shader index fb9d9ffbb..ad5b7d43f 100644 --- a/Assets/Gothic-Core/Shaders/Barrier.shader +++ b/Assets/Gothic-Core/Shaders/Barrier.shader @@ -26,6 +26,7 @@ Shader "Unlit/Barrier" #pragma fragment frag #include "UnityCG.cginc" + #pragma multi_compile_instancing struct appdata { @@ -81,7 +82,7 @@ Shader "Unlit/Barrier" fixed4 frag(v2f i) : SV_Target { fixed4 texColor = tex2D(_MainTex, i.uv); - return lerp((0, 0, 0, 0), texColor * i.color.a, _Blend); + return lerp(fixed4(0, 0, 0, 0), texColor * i.color.a, _Blend); } ENDCG } diff --git a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader index e5a59a57d..e1938be65 100644 --- a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader +++ b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader @@ -13,7 +13,7 @@ Shader "Lit/AlphaToCoverage-Dynamic" { Tags { - "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" "RenderQueue" = "AlphaTest" + "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" "Queue" = "AlphaTest" } AlphaToMask On @@ -23,6 +23,7 @@ Shader "Lit/AlphaToCoverage-Dynamic" #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog + #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" @@ -40,7 +41,6 @@ Shader "Lit/AlphaToCoverage-Dynamic" struct v2f { float4 vertex : SV_POSITION; - float3 normal : NORMAL; float4 uv : TEXCOORD0; float distance : TEXCOORD1; float3 worldPos : TEXCOORD2; @@ -76,7 +76,7 @@ Shader "Lit/AlphaToCoverage-Dynamic" // diffuse += AdditionalUnityLightDiffuse(light, i.normal); //} - for (int k = 0; k < min(_StationaryLightCount, MAX_AFFECTING_STATIONARY_LIGHTS); k++) + for (uint k = 0; k < min(_StationaryLightCount, MAX_AFFECTING_STATIONARY_LIGHTS); k++) { diffuse += AdditionalStationaryDiffuse(_StationaryLightIndices[k / 4][k % 4], worldPos, normal); } @@ -91,11 +91,11 @@ Shader "Lit/AlphaToCoverage-Dynamic" UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); - float3 toCamera = TransformObjectToWorld(v.vertex) - _WorldSpaceCameraPos; + float3 toCamera = TransformObjectToWorld(v.vertex.xyz) - _WorldSpaceCameraPos; o.distance = length(toCamera); - o.vertex = TransformObjectToHClip(v.vertex); + o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = float4(v.uv.xy * REFERENCE_TEX_ARRAY_SIZE * _MainTex_TexelSize.xy, v.uv.zw); - o.worldPos = TransformObjectToWorld(v.vertex); + o.worldPos = TransformObjectToWorld(v.vertex.xyz); o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; @@ -107,7 +107,7 @@ Shader "Lit/AlphaToCoverage-Dynamic" half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z, clamp(mipLevel, 0, i.uv.w)); // Rescale alpha by mip level since preserved coverage mip maps can't be generated at runtime. - albedo.a *= 1 + max(0, CalcMipLevel(i.uv * _MainTex_TexelSize.zw)) * _MipScale; + albedo.a *= 1 + max(0, CalcMipLevel(i.uv.xy * _MainTex_TexelSize.zw)) * _MipScale; // Rescale alpha by partial derivative, faded by distance. This way, at a distance, the wide coverage is kept to reduce aliasing further. albedo.a = lerp((albedo.a - _Cutoff) / max(fwidth(albedo.a), 0.0001) + 0.5, albedo.a, saturate(max(i.distance, 0.0001) / _DistanceFade)); diff --git a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader index b91312c49..3d9cb360e 100644 --- a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader +++ b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader @@ -12,7 +12,7 @@ Shader "Lit/AlphaToCoverage" { Tags { - "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" "RenderQueue" = "AlphaTest" + "RenderType" = "TransparentCutout" "RenderPipeline" = "UniversalPipeline" "Queue" = "AlphaTest" } AlphaToMask On @@ -22,6 +22,7 @@ Shader "Lit/AlphaToCoverage" #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog + #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" @@ -39,7 +40,6 @@ Shader "Lit/AlphaToCoverage" struct v2f { float4 vertex : SV_POSITION; - float3 normal : NORMAL; float4 uv : TEXCOORD0; float distance : TEXCOORD1; float3 worldPos : TEXCOORD2; @@ -74,7 +74,7 @@ Shader "Lit/AlphaToCoverage" // diffuse += AdditionalUnityLightDiffuse(light, i.normal); //} - for (int k = 0; k < min(_StationaryLightCount, MAX_AFFECTING_STATIONARY_LIGHTS); k++) + for (uint k = 0; k < min(_StationaryLightCount, MAX_AFFECTING_STATIONARY_LIGHTS); k++) { diffuse += AdditionalStationaryDiffuse(_StationaryLightIndices[k / 4][k % 4], worldPos, normal); } @@ -89,11 +89,11 @@ Shader "Lit/AlphaToCoverage" UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); - float3 toCamera = TransformObjectToWorld(v.vertex) - _WorldSpaceCameraPos; + float3 toCamera = TransformObjectToWorld(v.vertex.xyz) - _WorldSpaceCameraPos; o.distance = length(toCamera); - o.vertex = TransformObjectToHClip(v.vertex); + o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = float4(v.uv.xy * REFERENCE_TEX_ARRAY_SIZE * _MainTex_TexelSize.xy, v.uv.zw); - o.worldPos = TransformObjectToWorld(v.vertex); + o.worldPos = TransformObjectToWorld(v.vertex.xyz); o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; @@ -105,7 +105,7 @@ Shader "Lit/AlphaToCoverage" half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z, clamp(mipLevel, 0, i.uv.w)); // Rescale alpha by mip level since preserved coverage mip maps can't be generated at runtime. - albedo.a *= 1 + max(0, CalcMipLevel(i.uv * _MainTex_TexelSize.zw)) * _MipScale; + albedo.a *= 1 + max(0, CalcMipLevel(i.uv.xy * _MainTex_TexelSize.zw)) * _MipScale; // Rescale alpha by partial derivative, faded by distance. This way, at a distance, the wide coverage is kept to reduce aliasing further. albedo.a = lerp((albedo.a - _Cutoff) / max(fwidth(albedo.a), 0.0001) + 0.5, albedo.a, saturate(max(i.distance, 0.0001) / _DistanceFade)); diff --git a/Assets/Gothic-Core/Shaders/Lit-Misc.shader b/Assets/Gothic-Core/Shaders/Lit-Misc.shader index ae034b926..dcf4f9969 100644 --- a/Assets/Gothic-Core/Shaders/Lit-Misc.shader +++ b/Assets/Gothic-Core/Shaders/Lit-Misc.shader @@ -136,32 +136,18 @@ Shader "Gothic/Lit/Misc" // Universal Pipeline keywords #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS - #pragma multi_compile _ EVALUATE_SH_MIXED EVALUATE_SH_VERTEX #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS - #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING - #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile_fragment _ _SCREEN_SPACE_OCCLUSION #pragma multi_compile_fragment _ _DBUFFER_MRT1 _DBUFFER_MRT2 _DBUFFER_MRT3 - #pragma multi_compile_fragment _ _LIGHT_COOKIES - #pragma multi_compile _ _LIGHT_LAYERS - #pragma multi_compile _ _FORWARD_PLUS + #pragma multi_compile _ _CLUSTER_LIGHT_LOOP #include_with_pragmas "Packages/com.unity.render-pipelines.core/ShaderLibrary/FoveatedRenderingKeywords.hlsl" #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl" // ------------------------------------- // Unity defined keywords - #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING - #pragma multi_compile _ SHADOWS_SHADOWMASK - #pragma multi_compile _ DIRLIGHTMAP_COMBINED - #pragma multi_compile _ LIGHTMAP_ON - #pragma multi_compile _ DYNAMICLIGHTMAP_ON - #pragma multi_compile _ USE_LEGACY_LIGHTMAPS - #pragma multi_compile _ LOD_FADE_CROSSFADE #pragma multi_compile_fog - #pragma multi_compile_fragment _ DEBUG_DISPLAY - #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/ProbeVolumeVariants.hlsl" //-------------------------------------- // GPU Instancing @@ -281,24 +267,15 @@ Shader "Gothic/Lit/Misc" #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN //#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS //#pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS - #pragma multi_compile_fragment _ _REFLECTION_PROBE_BLENDING - #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile_fragment _ _DBUFFER_MRT1 _DBUFFER_MRT2 _DBUFFER_MRT3 + #pragma multi_compile_fragment _ _REFLECTION_PROBE_BOX_PROJECTION #pragma multi_compile_fragment _ _RENDER_PASS_ENABLED #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/RenderingLayers.hlsl" // ------------------------------------- // Unity defined keywords - #pragma multi_compile _ LIGHTMAP_SHADOW_MIXING - #pragma multi_compile _ SHADOWS_SHADOWMASK - #pragma multi_compile _ DIRLIGHTMAP_COMBINED - #pragma multi_compile _ LIGHTMAP_ON - #pragma multi_compile _ DYNAMICLIGHTMAP_ON - #pragma multi_compile _ USE_LEGACY_LIGHTMAPS - #pragma multi_compile _ LOD_FADE_CROSSFADE #pragma multi_compile_fragment _ _GBUFFER_NORMALS_OCT - #include_with_pragmas "Packages/com.unity.render-pipelines.universal/ShaderLibrary/ProbeVolumeVariants.hlsl" //-------------------------------------- // GPU Instancing diff --git a/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader b/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader index 0d026e3bd..83403aefa 100644 --- a/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader +++ b/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader @@ -12,7 +12,7 @@ Shader "Lit/SingleMesh-Dynamic" { "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" - "RenderQueue" = "Transparent" + "Queue" = "Transparent" } Pass @@ -23,6 +23,7 @@ Shader "Lit/SingleMesh-Dynamic" #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog + #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" @@ -76,8 +77,8 @@ Shader "Lit/SingleMesh-Dynamic" UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); - o.worldPos = TransformObjectToWorld(v.vertex); - o.vertex = TransformObjectToHClip(v.vertex); + o.worldPos = TransformObjectToWorld(v.vertex.xyz); + o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = v.uv; o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; @@ -86,7 +87,7 @@ Shader "Lit/SingleMesh-Dynamic" half4 frag(v2f i) : SV_Target { half4 albedo = tex2D(_MainTex, i.uv); - half3 diffuse = albedo * i.diffuse * _FocusBrightness; + half3 diffuse = albedo.rgb * i.diffuse * _FocusBrightness; diffuse = ApplyUnderWaterEffect(diffuse); diff --git a/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader b/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader index db962d073..8f3dd19bb 100644 --- a/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader +++ b/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader @@ -10,7 +10,7 @@ Shader "Lit/SingleMesh" { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" - "RenderQueue" = "Geometry" + "Queue" = "Geometry" } Pass @@ -19,6 +19,7 @@ Shader "Lit/SingleMesh" #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog + #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" @@ -70,8 +71,8 @@ Shader "Lit/SingleMesh" UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); - o.worldPos = TransformObjectToWorld(v.vertex); - o.vertex = TransformObjectToHClip(v.vertex); + o.worldPos = TransformObjectToWorld(v.vertex.xyz); + o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = v.uv; o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; @@ -80,7 +81,7 @@ Shader "Lit/SingleMesh" half4 frag(v2f i) : SV_Target { half4 albedo = tex2D(_MainTex, i.uv); - half3 diffuse = albedo * i.diffuse; + half3 diffuse = albedo.rgb * i.diffuse; diffuse = ApplyUnderWaterEffect(diffuse); diff --git a/Assets/Gothic-Core/Shaders/Lit-Water.shader b/Assets/Gothic-Core/Shaders/Lit-Water.shader index 72d89a67f..94d6cf6e6 100644 --- a/Assets/Gothic-Core/Shaders/Lit-Water.shader +++ b/Assets/Gothic-Core/Shaders/Lit-Water.shader @@ -8,7 +8,7 @@ Shader "Lit/Water" { Tags { - "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" "RenderQueue" = "Transparent" + "RenderType" = "Transparent" "RenderPipeline" = "UniversalPipeline" "Queue" = "Transparent" } Pass @@ -21,6 +21,7 @@ Shader "Lit/Water" #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog + #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" @@ -28,8 +29,6 @@ Shader "Lit/Water" struct appdata { float4 vertex : POSITION; - half4 color : COLOR; - half3 normal : NORMAL; float4 uv : TEXCOORD0; // uv, array slice, max mip level float4 textureAnimation : TEXCOORD1; // linear anim x, linear anim y, frame count, fps @@ -39,12 +38,12 @@ Shader "Lit/Water" struct v2f { float4 vertex : SV_POSITION; - half3 normal : NORMAL; float4 uv : TEXCOORD0; float3 worldPos : TEXCOORD1; + // Animated textures occupy consecutive array slices; this is the current frame offset. + // nointerpolation: a frame index must never be blended between vertices. + nointerpolation float frameIndex : TEXCOORD2; half3 diffuse : COLOR; - int frameIndex : TEXCOORD2; - UNITY_VERTEX_OUTPUT_STEREO }; @@ -57,20 +56,6 @@ Shader "Lit/Water" #include "GothicIncludes.hlsl" - half3 DiffuseLighting(v2f i, appdata v) - { - half3 diffuse = _SunColor + _AmbientColor; - - //for (int j = 0; j < min(MAX_VISIBLE_LIGHTS, unity_LightData.y); j++) - //{ - // int lightIndex = GetPerObjectLightIndex(j); - // Light light = CustomGetAdditionalPerObjectLight(lightIndex, i.worldPos); - // diffuse += AdditionalUnityLightDiffuse(light, i.normal); - //} - - return diffuse; - } - v2f vert(appdata v) { v2f o; @@ -78,21 +63,22 @@ Shader "Lit/Water" UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); - o.worldPos = TransformObjectToWorld(v.vertex); - o.vertex = TransformObjectToHClip(v.vertex); + o.worldPos = TransformObjectToWorld(v.vertex.xyz); + o.vertex = TransformObjectToHClip(v.vertex.xyz); float2 movingUv = v.uv.xy * REFERENCE_TEX_ARRAY_SIZE * _MainTex_TexelSize.xy + v.textureAnimation.xy * _Time.y * 1000; o.uv = float4(movingUv, v.uv.zw); - o.normal = TransformObjectToWorldNormal(v.normal); - o.diffuse = DiffuseLighting(o, v); - o.frameIndex = (_Time.y * v.textureAnimation.w) % v.textureAnimation.z; + // textureAnimation.z is frameCount+1 (1 for non-animated textures -> frameIndex stays 0). + o.frameIndex = floor(fmod(_Time.y * v.textureAnimation.w, max(v.textureAnimation.z, 1.0))); + o.diffuse = _SunColor + _AmbientColor; return o; } half4 frag(v2f i) : SV_Target { float mipLevel = CalcMipLevel(i.uv.xy * _MainTex_TexelSize.zw); - half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z + i.frameIndex, clamp(mipLevel, 0, i.uv.w)); - half3 diffuse = albedo * i.diffuse; + half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z + i.frameIndex, + clamp(mipLevel, 0, i.uv.w)); + half3 diffuse = albedo.rgb * i.diffuse; diffuse = ApplyUnderWaterEffect(diffuse); diff --git a/Assets/Gothic-Core/Shaders/Lit-World.shader b/Assets/Gothic-Core/Shaders/Lit-World.shader index 376421192..f3c2afd1a 100644 --- a/Assets/Gothic-Core/Shaders/Lit-World.shader +++ b/Assets/Gothic-Core/Shaders/Lit-World.shader @@ -12,7 +12,7 @@ Shader "Lit/World" { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" - "RenderQueue" = "Geometry" + "Queue" = "Geometry" } Pass @@ -21,6 +21,7 @@ Shader "Lit/World" #pragma vertex vert #pragma fragment frag #pragma multi_compile_fog + #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" @@ -71,13 +72,13 @@ Shader "Lit/World" // diffuse += AdditionalUnityLightDiffuse(light, i.normal); //} - for (int k = 0; k < min(_StationaryLightCount, MAX_AFFECTING_STATIONARY_LIGHTS); k++) + for (uint k = 0; k < min(_StationaryLightCount, MAX_AFFECTING_STATIONARY_LIGHTS); k++) { diffuse += AdditionalStationaryDiffuse(_StationaryLightIndices[k / 4][k % 4], worldPos, normal); } if (_StationaryLightCount >= MAX_AFFECTING_STATIONARY_LIGHTS) { - for (int l = 0; l < min(_StationaryLightCount - MAX_AFFECTING_STATIONARY_LIGHTS, + for (uint l = 0; l < min(_StationaryLightCount - MAX_AFFECTING_STATIONARY_LIGHTS, MAX_AFFECTING_STATIONARY_LIGHTS); l++) { diffuse += AdditionalStationaryDiffuse(_StationaryLightIndices2[l / 4][l % 4], worldPos, @@ -95,8 +96,8 @@ Shader "Lit/World" UNITY_SETUP_INSTANCE_ID(v); UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o); - o.worldPos = TransformObjectToWorld(v.vertex); - o.vertex = TransformObjectToHClip(v.vertex); + o.worldPos = TransformObjectToWorld(v.vertex.xyz); + o.vertex = TransformObjectToHClip(v.vertex.xyz); o.uv = float4(v.uv.xy * REFERENCE_TEX_ARRAY_SIZE * _MainTex_TexelSize.xy, v.uv.zw); o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; @@ -107,7 +108,7 @@ Shader "Lit/World" float mipLevel = CalcMipLevel(i.uv.xy * _MainTex_TexelSize.zw); half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z, clamp(mipLevel, 0, i.uv.w)); - half3 diffuse = albedo * i.diffuse * _FocusBrightness; + half3 diffuse = albedo.rgb * i.diffuse * _FocusBrightness; diffuse = ApplyUnderWaterEffect(diffuse); From 490566b4740a885db60600bedcfce6176825ec90 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sun, 14 Jun 2026 01:17:06 +0300 Subject: [PATCH 5/9] perf: weld VOB mesh vertices --- .../Meshes/Builder/AbstractMeshBuilder.cs | 52 +++++++++++++++---- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs index 9c0fc5e6b..5fc0331f6 100644 --- a/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs +++ b/Assets/Gothic-Core/Scripts/Domain/Meshes/Builder/AbstractMeshBuilder.cs @@ -446,6 +446,9 @@ protected void PrepareMeshRenderer(Renderer rend, IMultiResolutionMesh mrmData) protected void PrepareMeshFilter(MeshFilter meshFilter, IMultiResolutionMesh mrmData, Renderer meshRenderer, int meshIndex, List calculatedVertices = null) { // ISoftSkinMeshes will be prepared before reaching this method. This is due to NPC armors having dedicated offsets per item. + // A non-null calculatedVertices is also our soft-skin signal: only that overload passes it, and its bone + // weights are filled afterwards in the un-welded 3-per-triangle order (see the ISoftSkinMesh overload). + var isSoftSkin = calculatedVertices != null; calculatedVertices ??= mrmData.Positions; var subMeshPerTextureFormat = new Dictionary(); @@ -470,12 +473,22 @@ protected void PrepareMeshFilter(MeshFilter meshFilter, IMultiResolutionMesh mrm int triangleCount = mrmData.SubMeshes.Sum(i => i.Triangles.Count); int vertexCount = triangleCount * 3; - int index = 0; var preparedVertices = new List(vertexCount); var preparedUVs = new List(vertexCount); var normals = new List(vertexCount); var preparedTriangles = new List>(); + // Weld identical corners to a single Unity vertex instead of emitting 3 fresh vertices per triangle. + // This removes the ~3x vertex bloat (and the vertex-shader + bandwidth cost it carries; on Quest the + // stationary lighting is computed per-vertex). Keying on the full (position, normal, uv) tuple is + // visually lossless: hard edges and UV seams keep their own vertices, only true duplicates collapse. + // Disabled for soft-skin (bone weights rely on the expanded order) and morph meshes (morph animation + // maps source positions to specific Unity vertices). + var weldVertices = !isSoftSkin && Mmb == null; + var weldMap = weldVertices + ? new Dictionary<(Vector3 pos, Vector3 normal, Vector4 uv), int>(vertexCount) + : null; + foreach (var subMesh in mrmData.SubMeshes) { // When using the texture array, get the index of the array of the matching texture format. Build sub meshes for each texture format, i.e. separating opaque and alpha cutout textures. @@ -504,14 +517,28 @@ protected void PrepareMeshFilter(MeshFilter meshFilter, IMultiResolutionMesh mrm void AddWedgeVertex(MeshWedge wedge) { - var position = calculatedVertices[wedge.Index]; - preparedVertices.Add(new Vector3(position.X / 100f, position.Y / 100f, position.Z / 100f)); - normals.Add(new Vector3(wedge.Normal.X, wedge.Normal.Y, wedge.Normal.Z)); - preparedUVs.Add(new Vector4(wedge.Texture.X * textureScale.x, wedge.Texture.Y * textureScale.y, - textureArrayIndex, maxMipLevel)); - triangleList.Add(index++); - - CreateMorphMeshEntry(wedge.Index, preparedVertices.Count); + var rawPosition = calculatedVertices[wedge.Index]; + var position = new Vector3(rawPosition.X / 100f, rawPosition.Y / 100f, rawPosition.Z / 100f); + var normal = new Vector3(wedge.Normal.X, wedge.Normal.Y, wedge.Normal.Z); + var uv = new Vector4(wedge.Texture.X * textureScale.x, wedge.Texture.Y * textureScale.y, + textureArrayIndex, maxMipLevel); + + if (weldVertices && weldMap.TryGetValue((position, normal, uv), out var existingIndex)) + { + triangleList.Add(existingIndex); + return; + } + + var vertexIndex = preparedVertices.Count; + preparedVertices.Add(position); + normals.Add(normal); + preparedUVs.Add(uv); + triangleList.Add(vertexIndex); + + if (weldVertices) + weldMap[(position, normal, uv)] = vertexIndex; + else + CreateMorphMeshEntry(wedge.Index, preparedVertices.Count); } var wedges = subMesh.Wedges; @@ -541,6 +568,13 @@ void AddWedgeVertex(MeshWedge wedge) CreateMorphMeshEnd(preparedVertices); + // Reorder the (now welded) index/vertex buffers for post-transform vertex-cache locality. Only useful + // once vertices are shared, and it reorders the vertex buffer - so it is strictly gated to welded meshes + // (never morph: their external positionIndex->vertex mapping would be invalidated; never soft-skin). + // Built once per unique mesh (cached by name), so the cost amortizes across all instances. + if (weldVertices) + mesh.Optimize(); + _multiTypeCacheService.Meshes.Add($"{MeshName}_{meshIndex}", mesh); } From 20def23f392a338d03f2f56b6db88094eaa7e922 Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sun, 14 Jun 2026 01:17:06 +0300 Subject: [PATCH 6/9] perf: remove duplicate mip calc in cutout shaders --- Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader | 3 ++- Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader index e1938be65..67591cacc 100644 --- a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader +++ b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage-Dynamic.shader @@ -107,7 +107,8 @@ Shader "Lit/AlphaToCoverage-Dynamic" half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z, clamp(mipLevel, 0, i.uv.w)); // Rescale alpha by mip level since preserved coverage mip maps can't be generated at runtime. - albedo.a *= 1 + max(0, CalcMipLevel(i.uv.xy * _MainTex_TexelSize.zw)) * _MipScale; + // Reuse mipLevel from above (CalcMipLevel already returns >= 0) instead of recomputing ddx/ddy + log2. + albedo.a *= 1 + mipLevel * _MipScale; // Rescale alpha by partial derivative, faded by distance. This way, at a distance, the wide coverage is kept to reduce aliasing further. albedo.a = lerp((albedo.a - _Cutoff) / max(fwidth(albedo.a), 0.0001) + 0.5, albedo.a, saturate(max(i.distance, 0.0001) / _DistanceFade)); diff --git a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader index 3d9cb360e..7a538e485 100644 --- a/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader +++ b/Assets/Gothic-Core/Shaders/Lit-AlphaToCoverage.shader @@ -105,7 +105,8 @@ Shader "Lit/AlphaToCoverage" half4 albedo = SAMPLE_TEXTURE2D_ARRAY_LOD(_MainTex, sampler_MainTex, i.uv.xy, i.uv.z, clamp(mipLevel, 0, i.uv.w)); // Rescale alpha by mip level since preserved coverage mip maps can't be generated at runtime. - albedo.a *= 1 + max(0, CalcMipLevel(i.uv.xy * _MainTex_TexelSize.zw)) * _MipScale; + // Reuse mipLevel from above (CalcMipLevel already returns >= 0) instead of recomputing ddx/ddy + log2. + albedo.a *= 1 + mipLevel * _MipScale; // Rescale alpha by partial derivative, faded by distance. This way, at a distance, the wide coverage is kept to reduce aliasing further. albedo.a = lerp((albedo.a - _Cutoff) / max(fwidth(albedo.a), 0.0001) + 0.5, albedo.a, saturate(max(i.distance, 0.0001) / _DistanceFade)); From 9bc16074644221aa90ecd7028f0cee0e6d84733e Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Sun, 14 Jun 2026 01:17:06 +0300 Subject: [PATCH 7/9] perf: make single-mesh shaders SRP Batcher compatible --- .../Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader | 11 ++++++++--- Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader b/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader index 83403aefa..491d4e963 100644 --- a/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader +++ b/Assets/Gothic-Core/Shaders/Lit-SingleMesh-Dynamic.shader @@ -48,8 +48,13 @@ Shader "Lit/SingleMesh-Dynamic" UNITY_VERTEX_OUTPUT_STEREO }; + // Texture/sampler live outside the CBUFFER (a sampler inside UnityPerMaterial makes the shader + // SRP-Batcher-incompatible). + TEXTURE2D(_MainTex); + SAMPLER(sampler_MainTex); + CBUFFER_START(UnityPerMaterial) - sampler2D _MainTex; + float4 _MainTex_ST; float _FocusBrightness; float _Alpha; CBUFFER_END @@ -79,14 +84,14 @@ Shader "Lit/SingleMesh-Dynamic" o.worldPos = TransformObjectToWorld(v.vertex.xyz); o.vertex = TransformObjectToHClip(v.vertex.xyz); - o.uv = v.uv; + o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; } half4 frag(v2f i) : SV_Target { - half4 albedo = tex2D(_MainTex, i.uv); + half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); half3 diffuse = albedo.rgb * i.diffuse * _FocusBrightness; diffuse = ApplyUnderWaterEffect(diffuse); diff --git a/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader b/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader index 8f3dd19bb..f9d343680 100644 --- a/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader +++ b/Assets/Gothic-Core/Shaders/Lit-SingleMesh.shader @@ -44,8 +44,13 @@ Shader "Lit/SingleMesh" UNITY_VERTEX_OUTPUT_STEREO }; + // Texture/sampler live outside the CBUFFER (a sampler inside UnityPerMaterial makes the shader + // SRP-Batcher-incompatible). Only the _ST stays as the per-material constant. + TEXTURE2D(_MainTex); + SAMPLER(sampler_MainTex); + CBUFFER_START(UnityPerMaterial) - sampler2D _MainTex; + float4 _MainTex_ST; CBUFFER_END #include "GothicIncludes.hlsl" @@ -73,14 +78,14 @@ Shader "Lit/SingleMesh" o.worldPos = TransformObjectToWorld(v.vertex.xyz); o.vertex = TransformObjectToHClip(v.vertex.xyz); - o.uv = v.uv; + o.uv = TRANSFORM_TEX(v.uv, _MainTex); o.diffuse = DiffuseLighting(TransformObjectToWorldNormal(v.normal), o.worldPos, v.color); return o; } half4 frag(v2f i) : SV_Target { - half4 albedo = tex2D(_MainTex, i.uv); + half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv); half3 diffuse = albedo.rgb * i.diffuse; diffuse = ApplyUnderWaterEffect(diffuse); From d79ffcd855a3a30141eb3c66b6ee7480446c83eb Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Mon, 15 Jun 2026 15:50:43 +0300 Subject: [PATCH 8/9] fix: remove sun diffuse --- Assets/Gothic-Core/Shaders/Lit-World.shader | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Gothic-Core/Shaders/Lit-World.shader b/Assets/Gothic-Core/Shaders/Lit-World.shader index f3c2afd1a..4f2e9185e 100644 --- a/Assets/Gothic-Core/Shaders/Lit-World.shader +++ b/Assets/Gothic-Core/Shaders/Lit-World.shader @@ -63,7 +63,7 @@ Shader "Lit/World" half3 DiffuseLighting(half3 normal, float3 worldPos, half3 color) { - half3 diffuse = SunAndAmbientDiffuse(normal, color); + half3 diffuse = color; //for (int j = 0; j < min(MAX_VISIBLE_LIGHTS, unity_LightData.y); j++) //{ From 8a9817510c3f159debeb106dffd18020f67047ff Mon Sep 17 00:00:00 2001 From: Jucan Andrei Daniel Date: Mon, 15 Jun 2026 15:51:46 +0300 Subject: [PATCH 9/9] fix: change static light attenuation to linear --- Assets/Gothic-Core/Shaders/StationaryLighting.hlsl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Gothic-Core/Shaders/StationaryLighting.hlsl b/Assets/Gothic-Core/Shaders/StationaryLighting.hlsl index f4ee3eb61..80b7a6a07 100644 --- a/Assets/Gothic-Core/Shaders/StationaryLighting.hlsl +++ b/Assets/Gothic-Core/Shaders/StationaryLighting.hlsl @@ -12,11 +12,11 @@ half3 AdditionalStationaryDiffuse(uint lightIndex, real3 worldPos, real3 normal) float4 lightPosAndAttenuation = _GlobalStationaryLightPositionsAndAttenuation[lightIndex]; float3 lightVector = lightPosAndAttenuation.xyz - worldPos; float distanceSqr = max(dot(lightVector, lightVector), HALF_MIN); - half3 lightDirection = half3(lightVector * rsqrt(distanceSqr)); - float diffuseDot = saturate(dot(lightDirection, normal)); - return _GlobalStationaryLightColors[lightIndex] * CustomDistanceAttenuation(distanceSqr, lightPosAndAttenuation.w) * - diffuseDot * _PointLightIntensity; + // Linear distance falloff (1 - d/R) + float attenuation = saturate(1.0 - sqrt(distanceSqr * lightPosAndAttenuation.w)); + + return _GlobalStationaryLightColors[lightIndex] * attenuation * _PointLightIntensity; } #endif