diff --git a/src/rendering/water-material.js b/src/rendering/water-material.js index c3fc2338..ba8cb414 100644 --- a/src/rendering/water-material.js +++ b/src/rendering/water-material.js @@ -265,6 +265,16 @@ function createPatchFromProjectedPoints(projectedPoints, preferredAxis, minimumT }; } +function computeTriangleNormal(pointA, pointB, pointC) { + const edgeAB = pointB.clone().sub(pointA); + const edgeAC = pointC.clone().sub(pointA); + const normal = edgeAB.cross(edgeAC); + if (normal.lengthSq() <= WATER_CONTACT_EPSILON) { + return null; + } + return normal.normalize(); +} + function createPatchCornerPoints(patch) { const axis = new Vector2(patch.axisX, patch.axisZ); if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { @@ -305,11 +315,33 @@ function measurePatchExtentsInBasis(points, axis) { }; } -function mergeTriangleMeshContactPatches(rawPatches, minimumThickness) { +function getTriangleMeshMergeSettings(mergeProfile, minimumThickness) { + if (mergeProfile === "aggressive") { + return { + axisAlignment: 0.88, + normalAlignment: 0.9, + minimumPrimaryGap: Math.max(0.26, minimumThickness * 2.8), + minimumSecondaryGap: Math.max(0.18, minimumThickness * 2.2), + primaryGapScale: 0.34, + secondaryGapScale: 0.55 + }; + } + return { + axisAlignment: 0.95, + normalAlignment: 0.97, + minimumPrimaryGap: Math.max(0.08, minimumThickness * 1.25), + minimumSecondaryGap: Math.max(0.1, minimumThickness * 1.4), + primaryGapScale: 0.12, + secondaryGapScale: 0.3 + }; +} + +function mergeTriangleMeshContactPatches(rawPatches, minimumThickness, mergeProfile) { + const mergeSettings = getTriangleMeshMergeSettings(mergeProfile, minimumThickness); const clusters = []; - for (const patch of rawPatches) { - const patchPoints = createPatchCornerPoints(patch); - const patchAxis = new Vector2(patch.axisX, patch.axisZ); + for (const rawPatch of rawPatches) { + const patchPoints = createPatchCornerPoints(rawPatch.patch); + const patchAxis = new Vector2(rawPatch.patch.axisX, rawPatch.patch.axisZ); if (patchAxis.lengthSq() <= WATER_CONTACT_EPSILON) { patchAxis.set(1, 0); } @@ -319,14 +351,20 @@ function mergeTriangleMeshContactPatches(rawPatches, minimumThickness) { let merged = false; for (const cluster of clusters) { const alignment = Math.abs(cluster.axis.dot(patchAxis)); - if (alignment < 0.92) { + if (alignment < mergeSettings.axisAlignment) { + continue; + } + const normalAlignment = Math.abs(cluster.normal.dot(rawPatch.normal)); + if (normalAlignment < mergeSettings.normalAlignment) { continue; } const patchExtents = measurePatchExtentsInBasis(patchPoints, cluster.axis); const primaryGap = Math.max(0, Math.max(cluster.extents.minPrimary - patchExtents.maxPrimary, patchExtents.minPrimary - cluster.extents.maxPrimary)); const secondaryGap = Math.max(0, Math.max(cluster.extents.minSecondary - patchExtents.maxSecondary, patchExtents.minSecondary - cluster.extents.maxSecondary)); - const allowedPrimaryGap = Math.max(0.18, Math.max(patch.halfWidth, (cluster.extents.maxPrimary - cluster.extents.minPrimary) * 0.12)); - const allowedSecondaryGap = Math.max(minimumThickness * 1.5, Math.max(patch.halfDepth, (cluster.extents.maxSecondary - cluster.extents.minSecondary) * 0.35)); + const clusterPrimarySpan = cluster.extents.maxPrimary - cluster.extents.minPrimary; + const clusterSecondarySpan = cluster.extents.maxSecondary - cluster.extents.minSecondary; + const allowedPrimaryGap = Math.max(mergeSettings.minimumPrimaryGap, Math.max(rawPatch.patch.halfWidth, clusterPrimarySpan) * mergeSettings.primaryGapScale); + const allowedSecondaryGap = Math.max(mergeSettings.minimumSecondaryGap, Math.max(rawPatch.patch.halfDepth, clusterSecondarySpan) * mergeSettings.secondaryGapScale); if (primaryGap > allowedPrimaryGap || secondaryGap > allowedSecondaryGap) { continue; } @@ -338,6 +376,7 @@ function mergeTriangleMeshContactPatches(rawPatches, minimumThickness) { if (!merged) { clusters.push({ axis: patchAxis, + normal: rawPatch.normal.clone(), points: patchPoints.map((point) => point.clone()), extents: measurePatchExtentsInBasis(patchPoints, patchAxis) }); @@ -372,16 +411,23 @@ function appendTriangleMeshContactPatches(patches, source, volume, inverseRotati vertex.applyQuaternion(inverseRotation); polygon.push(vertex.clone()); } + const triangleNormal = computeTriangleNormal(polygon[0] ?? new Vector3(), polygon[1] ?? new Vector3(), polygon[2] ?? new Vector3()); + if (triangleNormal === null) { + continue; + } const clippedPolygon = clipPolygonToContactVolume(polygon, halfX, surfaceY - surfaceBand, surfaceY + surfaceBand, halfZ); if (clippedPolygon.length < 2) { continue; } const patch = createPatchFromProjectedPoints(clippedPolygon.map((point) => new Vector2(point.x, point.z)), null, bandMinimumThickness); if (patch !== null) { - rawPatches.push(patch); + rawPatches.push({ + patch, + normal: triangleNormal + }); } } - patches.push(...mergeTriangleMeshContactPatches(rawPatches, bandMinimumThickness)); + patches.push(...mergeTriangleMeshContactPatches(rawPatches, bandMinimumThickness, source.mergeProfile)); } export function collectWaterContactPatches(volume, contactBounds) { diff --git a/src/rendering/water-material.ts b/src/rendering/water-material.ts index aa4a7899..eb7f77b1 100644 --- a/src/rendering/water-material.ts +++ b/src/rendering/water-material.ts @@ -18,6 +18,7 @@ export interface WaterContactTriangleMesh { kind: "triangleMesh"; vertices: Float32Array; indices: Uint32Array; + mergeProfile?: "default" | "aggressive"; transform?: { position: Vec3; rotationDegrees: Vec3; @@ -65,6 +66,11 @@ interface OrientedWaterVolume { size: Vec3; } +interface TriangleMeshPatchSample { + patch: WaterContactPatch; + normal: Vector3; +} + const MAX_WATER_CONTACT_PATCHES = 6; const WATER_CONTACT_EPSILON = 1e-4; @@ -392,6 +398,18 @@ function createPatchFromProjectedPoints(projectedPoints: Vector2[], preferredAxi }; } +function computeTriangleNormal(pointA: Vector3, pointB: Vector3, pointC: Vector3) { + const edgeAB = pointB.clone().sub(pointA); + const edgeAC = pointC.clone().sub(pointA); + const normal = edgeAB.cross(edgeAC); + + if (normal.lengthSq() <= WATER_CONTACT_EPSILON) { + return null; + } + + return normal.normalize(); +} + function createPatchCornerPoints(patch: WaterContactPatch) { const axis = new Vector2(patch.axisX, patch.axisZ); if (axis.lengthSq() <= WATER_CONTACT_EPSILON) { @@ -434,16 +452,40 @@ function measurePatchExtentsInBasis(points: Vector2[], axis: Vector2) { }; } -function mergeTriangleMeshContactPatches(rawPatches: WaterContactPatch[], minimumThickness: number) { +function getTriangleMeshMergeSettings(mergeProfile: WaterContactTriangleMesh["mergeProfile"], minimumThickness: number) { + if (mergeProfile === "aggressive") { + return { + axisAlignment: 0.88, + normalAlignment: 0.9, + minimumPrimaryGap: Math.max(0.26, minimumThickness * 2.8), + minimumSecondaryGap: Math.max(0.18, minimumThickness * 2.2), + primaryGapScale: 0.34, + secondaryGapScale: 0.55 + }; + } + + return { + axisAlignment: 0.95, + normalAlignment: 0.97, + minimumPrimaryGap: Math.max(0.08, minimumThickness * 1.25), + minimumSecondaryGap: Math.max(0.1, minimumThickness * 1.4), + primaryGapScale: 0.12, + secondaryGapScale: 0.3 + }; +} + +function mergeTriangleMeshContactPatches(rawPatches: TriangleMeshPatchSample[], minimumThickness: number, mergeProfile: WaterContactTriangleMesh["mergeProfile"]) { + const mergeSettings = getTriangleMeshMergeSettings(mergeProfile, minimumThickness); const clusters: Array<{ axis: Vector2; + normal: Vector3; points: Vector2[]; extents: ReturnType; }> = []; - for (const patch of rawPatches) { - const patchPoints = createPatchCornerPoints(patch); - const patchAxis = new Vector2(patch.axisX, patch.axisZ); + for (const rawPatch of rawPatches) { + const patchPoints = createPatchCornerPoints(rawPatch.patch); + const patchAxis = new Vector2(rawPatch.patch.axisX, rawPatch.patch.axisZ); if (patchAxis.lengthSq() <= WATER_CONTACT_EPSILON) { patchAxis.set(1, 0); } else { @@ -454,15 +496,28 @@ function mergeTriangleMeshContactPatches(rawPatches: WaterContactPatch[], minimu for (const cluster of clusters) { const alignment = Math.abs(cluster.axis.dot(patchAxis)); - if (alignment < 0.92) { + if (alignment < mergeSettings.axisAlignment) { + continue; + } + + const normalAlignment = Math.abs(cluster.normal.dot(rawPatch.normal)); + if (normalAlignment < mergeSettings.normalAlignment) { continue; } const patchExtents = measurePatchExtentsInBasis(patchPoints, cluster.axis); const primaryGap = Math.max(0, Math.max(cluster.extents.minPrimary - patchExtents.maxPrimary, patchExtents.minPrimary - cluster.extents.maxPrimary)); const secondaryGap = Math.max(0, Math.max(cluster.extents.minSecondary - patchExtents.maxSecondary, patchExtents.minSecondary - cluster.extents.maxSecondary)); - const allowedPrimaryGap = Math.max(0.18, Math.max(patch.halfWidth, (cluster.extents.maxPrimary - cluster.extents.minPrimary) * 0.12)); - const allowedSecondaryGap = Math.max(minimumThickness * 1.5, Math.max(patch.halfDepth, (cluster.extents.maxSecondary - cluster.extents.minSecondary) * 0.35)); + const clusterPrimarySpan = cluster.extents.maxPrimary - cluster.extents.minPrimary; + const clusterSecondarySpan = cluster.extents.maxSecondary - cluster.extents.minSecondary; + const allowedPrimaryGap = Math.max( + mergeSettings.minimumPrimaryGap, + Math.max(rawPatch.patch.halfWidth, clusterPrimarySpan) * mergeSettings.primaryGapScale + ); + const allowedSecondaryGap = Math.max( + mergeSettings.minimumSecondaryGap, + Math.max(rawPatch.patch.halfDepth, clusterSecondarySpan) * mergeSettings.secondaryGapScale + ); if (primaryGap > allowedPrimaryGap || secondaryGap > allowedSecondaryGap) { continue; @@ -477,6 +532,7 @@ function mergeTriangleMeshContactPatches(rawPatches: WaterContactPatch[], minimu if (!merged) { clusters.push({ axis: patchAxis, + normal: rawPatch.normal.clone(), points: patchPoints.map((point) => point.clone()), extents: measurePatchExtentsInBasis(patchPoints, patchAxis) }); @@ -511,7 +567,7 @@ function appendTriangleMeshContactPatches( ); const bandMinimumThickness = Math.max(0.08, Math.min(0.22, surfaceBand * 0.45)); const triangleVertices = [new Vector3(), new Vector3(), new Vector3()]; - const rawPatches: WaterContactPatch[] = []; + const rawPatches: TriangleMeshPatchSample[] = []; for (let indexOffset = 0; indexOffset <= source.indices.length - 3; indexOffset += 3) { const polygon: Vector3[] = []; @@ -532,6 +588,11 @@ function appendTriangleMeshContactPatches( polygon.push(vertex.clone()); } + const triangleNormal = computeTriangleNormal(polygon[0] ?? new Vector3(), polygon[1] ?? new Vector3(), polygon[2] ?? new Vector3()); + if (triangleNormal === null) { + continue; + } + const clippedPolygon = clipPolygonToContactVolume(polygon, halfX, surfaceY - surfaceBand, surfaceY + surfaceBand, halfZ); if (clippedPolygon.length < 2) { @@ -545,11 +606,14 @@ function appendTriangleMeshContactPatches( ); if (patch !== null) { - rawPatches.push(patch); + rawPatches.push({ + patch, + normal: triangleNormal + }); } } - patches.push(...mergeTriangleMeshContactPatches(rawPatches, bandMinimumThickness)); + patches.push(...mergeTriangleMeshContactPatches(rawPatches, bandMinimumThickness, source.mergeProfile)); } export function collectWaterContactPatches(volume: OrientedWaterVolume, contactBounds: WaterContactSource[]): WaterContactPatch[] { diff --git a/src/runtime-three/runtime-host.js b/src/runtime-three/runtime-host.js index f44a6121..6c2cbc12 100644 --- a/src/runtime-three/runtime-host.js +++ b/src/runtime-three/runtime-host.js @@ -620,6 +620,7 @@ export class RuntimeHost { kind: "triangleMesh", vertices: collider.vertices, indices: collider.indices, + mergeProfile: "aggressive", transform: collider.transform }); continue; diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index b23e30c8..0e761bf8 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -847,6 +847,7 @@ export class RuntimeHost { kind: "triangleMesh", vertices: collider.vertices, indices: collider.indices, + mergeProfile: "aggressive", transform: collider.transform }); continue; diff --git a/src/viewport-three/viewport-host.js b/src/viewport-three/viewport-host.js index 27e9a306..a2cfea28 100644 --- a/src/viewport-three/viewport-host.js +++ b/src/viewport-three/viewport-host.js @@ -2236,6 +2236,7 @@ export class ViewportHost { kind: "triangleMesh", vertices: generatedCollider.vertices, indices: generatedCollider.indices, + mergeProfile: "aggressive", transform: generatedCollider.transform }); } diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index a5e81540..57dc7171 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -3051,6 +3051,7 @@ export class ViewportHost { kind: "triangleMesh", vertices: generatedCollider.vertices, indices: generatedCollider.indices, + mergeProfile: "aggressive", transform: generatedCollider.transform }); } else { diff --git a/tests/domain/water-material.test.ts b/tests/domain/water-material.test.ts index cc00fc27..9fb42a15 100644 --- a/tests/domain/water-material.test.ts +++ b/tests/domain/water-material.test.ts @@ -298,6 +298,7 @@ describe("water material helpers", () => { [ { kind: "triangleMesh", + mergeProfile: "aggressive", vertices: new Float32Array([ -2, 0, -1, 0, 0, -1, @@ -338,6 +339,153 @@ describe("water material helpers", () => { expect(patches[0]?.halfDepth ?? 0).toBeGreaterThan(0.05); }); + it("only uses aggressive merging for explicitly marked triangle meshes", () => { + const sharedSource = { + kind: "triangleMesh" as const, + vertices: new Float32Array([ + -2, 0, -1, + -0.3, 0, -1, + -0.3, 0, 1, + -2, 0, 1, + 0.3, 0, -1, + 2, 0, -1, + 2, 0, 1, + 0.3, 0, 1 + ]), + indices: new Uint32Array([ + 0, 1, 2, + 0, 2, 3, + 4, 5, 6, + 4, 6, 7 + ]), + transform: { + position: { + x: 0, + y: 1, + z: 0 + }, + rotationDegrees: { + x: 26, + y: 14, + z: 10 + }, + scale: { + x: 1, + y: 1, + z: 1 + } + } + }; + + const defaultPatches = collectWaterContactPatches( + { + center: { + x: 0, + y: 0, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 10, + y: 2, + z: 10 + } + }, + [sharedSource] + ); + const aggressivePatches = collectWaterContactPatches( + { + center: { + x: 0, + y: 0, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 10, + y: 2, + z: 10 + } + }, + [{ + ...sharedSource, + mergeProfile: "aggressive" as const + }] + ); + + expect(defaultPatches.length).toBeGreaterThan(1); + expect(aggressivePatches).toHaveLength(1); + }); + + it("does not merge sharply bent triangle mesh strips even in aggressive mode", () => { + const patches = collectWaterContactPatches( + { + center: { + x: 0, + y: 0, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + size: { + x: 10, + y: 2, + z: 10 + } + }, + [ + { + kind: "triangleMesh", + mergeProfile: "aggressive", + vertices: new Float32Array([ + -2, 0, -1, + 0, 0, -1, + 0, 0, 1, + -2, 0, 1, + 2, 1.6, -1, + 2, 1.6, 1 + ]), + indices: new Uint32Array([ + 0, 1, 2, + 0, 2, 3, + 1, 4, 5, + 1, 5, 2 + ]), + transform: { + position: { + x: 0, + y: 1, + z: 0 + }, + rotationDegrees: { + x: 0, + y: 0, + z: 0 + }, + scale: { + x: 1, + y: 1, + z: 1 + } + } + } + ] + ); + + expect(patches.length).toBeGreaterThan(1); + }); + it("builds a shared quality shader material for visible tinted water", () => { const result = createWaterMaterial({ colorHex: "#4da6d9",