I've been experimenting with a method to create an outline effect on 3D meshes, and here is my approach:
- Start by drawing the original shaded mesh
- Smooth out the mesh normals
- Move the vertices outward according to their normals
- Render only the back faces with a solid outline color
Everything seems to be in working order except for the smoothing part. Smoothing the mesh is crucial for achieving a seamless outer outline (otherwise, there might be discrepancies at the angles). I've previously inquired about smoothing mesh normals. The process of smoothing goes like this:
let geometry = new THREE.BoxGeometry();
geometry.deleteAttribute('normal');
geometry.deleteAttribute('uv');
geometry = THREE.BufferGeometryUtils.mergeVertices(geometry);
geometry.computeVertexNormals();
While this method works well for simple, well-defined geometries, it tends to produce artifacts on more complex models. I suspect this is due to discarding the original normals. computeVertexNormals()
ends up interpreting some faces incorrectly.
Any suggestions on how to resolve this issue?
Before smoothing normals:
https://i.sstatic.net/JHgZj.png
After smoothing normals:
https://i.sstatic.net/7fxbV.png
Sidenotes: 1/ Modifying the original mesh topology isn't feasible as they are dynamically generated using jscad.
2/ I attempted to devise an algorithm to address this but with limited success. My thought process was as follows:
Assuming computeVertexNormals()
determines whether a triangle is face-up or face-down based on the vertex order, I planned to identify triangles with inverted normal vertices and swap two vertices to correct it. A vertex normal is considered inverted if the dot product of the normal after computeVertexNormals()
differs from before.
Below is the code snippet for that:
const fixNormals: (bufferGeometry: THREE.BufferGeometry) => THREE.BufferGeometry
= (bufferGeometry) => {
// This function is designed specifically for non-indexed buffer geometry
if (bufferGeometry.index)
return bufferGeometry;
const positionAttribute = bufferGeometry.getAttribute('position');
if (!positionAttribute)
return bufferGeometry;
let oldNormalAttribute = bufferGeometry.getAttribute('normal').clone();
if (!oldNormalAttribute)
return bufferGeometry;
bufferGeometry.deleteAttribute('normal');
bufferGeometry.deleteAttribute('uv');
bufferGeometry.computeVertexNormals();
let normalAttribute = bufferGeometry.getAttribute('normal');
if (!normalAttribute) {
console.error("bufferGeometry.computeVertexNormals() resulted in empty normals")
return bufferGeometry;
}
const pA = new THREE.Vector3(),
pB = new THREE.Vector3(),
pC = new THREE.Vector3();
const onA = new THREE.Vector3(),
onB = new THREE.Vector3(),
onC = new THREE.Vector3();
const nA = new THREE.Vector3(),
nB = new THREE.Vector3(),
nC = new THREE.Vector3();
for (let i = 0, il = positionAttribute.count; i < il; i += 3) {
pA.fromBufferAttribute(positionAttribute, i + 0);
pB.fromBufferAttribute(positionAttribute, i + 1);
pC.fromBufferAttribute(positionAttribute, i + 2);
onA.fromBufferAttribute(oldNormalAttribute, i + 0);
onB.fromBufferAttribute(oldNormalAttribute, i + 1);
onC.fromBufferAttribute(oldNormalAttribute, i + 2);
nA.fromBufferAttribute(normalAttribute, i + 0);
nB.fromBufferAttribute(normalAttribute, i + 1);
nC.fromBufferAttribute(normalAttribute, i + 2);
// Determine new normals for this triangle -- invert them,
if (onA.dot(nA) < 0 && onB.dot(nB) < 0 && onC.dot(nC) < 0) {
positionAttribute.setXYZ(i + 0, pB.x, pB.y, pB.z)
positionAttribute.setXYZ(i + 1, pA.x, pA.y, pA.z)
}
}
bufferGeometry.deleteAttribute('normal');
bufferGeometry.deleteAttribute('uv');
bufferGeometry.computeVertexNormals();
return bufferGeometry;
}