I'm working on a project with 3D objects and decided to try using Three.js. I need a wireframe with rounded corners on a single axis. I've been able to achieve this effect mostly with two different methods, but not entirely. I'm not able to get a single outline connecting the two faces on just the outside wall.
I am drawing a shape with a new THREE.Shape() and then extrude it to geometry with a new THREE.ExtrudeGeometry(), getting the edges I care about with a new THREE.EdgesGeometry() and then finally making a mesh with a new THREE.LineSegments().
I'm also rendering a version of the box within it so the lines in the back are not shown.
The first way I tried was using the depth attribute on ExtrudeGeometry. It draws the lines, but it draws way too many lines, and it still draws them on the faces showing. I tried experimenting with the second parameter in EdgesGeometry which allows you to specify the minimum angle, but it either renders all or none of the lines.
The second thing I tried was making two separate shapes, extruding them with a depth of 0 and then repositing the two meshes slightly to give them depth.
This works much closer to what I wanted, but I'm still not getting the connection at the edges as I want.
Is this reasonably possible?
Here is a snippet demonstrating method 1
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
const renderer = new THREE.WebGLRenderer( { alpha: true });
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
//Shape code starts here
const w = 1.5;
const h = 1.5;
const r = 0.2;
const d = 0.4;
//Generate the rounded rect shape
const s = new THREE.Shape();
s.moveTo(-w / 2, -h / 2 + r);
s.lineTo(-w / 2, h / 2 - r);
s.absarc(-w / 2 + r, h / 2 - r, r, 1 * Math.PI, 0.5 * Math.PI, true);
s.lineTo(w / 2 - r, h / 2);
s.absarc(w / 2 - r, h / 2 - r, r, 0.5 * Math.PI, 0 * Math.PI, true);
s.lineTo(w / 2, -h / 2 + r);
s.absarc(w / 2 - r, -h / 2 + r, r, 2 * Math.PI, 1.5 * Math.PI, true);
s.lineTo(-w / 2 + r, -h / 2);
s.absarc(-w / 2 + r, -h / 2 + r, r, 1.5 * Math.PI, 1 * Math.PI, true);
const outlineMat = new THREE.LineBasicMaterial( {
color: 0x000000,
linewidth: 2,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
} );
//This is the material that is used to stop the
//lines in the back from showing
var innerMat = new THREE.MeshBasicMaterial( {
color: false,
side: THREE.DoubleSide,
depthTest: true,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
} );
//Using the shape, generate the meshes
const outlineMesh = new THREE.LineSegments(
new THREE.EdgesGeometry(
new THREE.ExtrudeGeometry(
s,
{
depth: d,
bevelEnabled: false
}
),
1 //This is the default min angle
),
outlineMat
);
const innerMesh = new THREE.Mesh(
new THREE.ExtrudeGeometry(
s,
{
depth: d,
bevelEnabled: false
}
),
innerMat
);
const group = new THREE.Group();
group.add(outlineMesh);
group.add(innerMesh);
scene.add(group);
//Shape code ends here
camera.position.z = 5;
function animate() {
requestAnimationFrame( animate );
group.rotateX(0.01);
group.rotateY(0.01);
renderer.render( scene, camera );
}
animate();
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
and here is one for method 2
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
const renderer = new THREE.WebGLRenderer( { alpha: true });
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
//Shape code starts here
const w = 1.5;
const h = 1.5;
const r = 0.2;
const d = 0.4;
//Generate the rounded rect shape
const s = new THREE.Shape();
s.moveTo(-w / 2, -h / 2 + r);
s.lineTo(-w / 2, h / 2 - r);
s.absarc(-w / 2 + r, h / 2 - r, r, 1 * Math.PI, 0.5 * Math.PI, true);
s.lineTo(w / 2 - r, h / 2);
s.absarc(w / 2 - r, h / 2 - r, r, 0.5 * Math.PI, 0 * Math.PI, true);
s.lineTo(w / 2, -h / 2 + r);
s.absarc(w / 2 - r, -h / 2 + r, r, 2 * Math.PI, 1.5 * Math.PI, true);
s.lineTo(-w / 2 + r, -h / 2);
s.absarc(-w / 2 + r, -h / 2 + r, r, 1.5 * Math.PI, 1 * Math.PI, true);
const outlineMat = new THREE.LineBasicMaterial( {
color: 0x000000,
linewidth: 2,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
} );
//This is the material that is used to stop the
//lines in the back from showing
var innerMat = new THREE.MeshBasicMaterial( {
color: false,
side: THREE.DoubleSide,
depthTest: true,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
} );
//Using the shape, generate the meshes
const frontMesh = new THREE.LineSegments(
new THREE.EdgesGeometry(
new THREE.ExtrudeGeometry(
s,
{
depth: 0,
bevelEnabled: false
}
),
1 //This is the default min angle
),
outlineMat
);
const backMesh = new THREE.LineSegments(
new THREE.EdgesGeometry(
new THREE.ExtrudeGeometry(
s,
{
depth: 0,
bevelEnabled: false
}
),
1 //This is the default min angle
),
outlineMat
);
const innerMesh = new THREE.Mesh(
new THREE.ExtrudeGeometry(
s,
{
depth: d,
bevelEnabled: false
}
),
innerMat
);
//Position the meshes
frontMesh.translateZ(d / 2);
backMesh.translateZ(-d / 2);
innerMesh.translateZ(-d / 2);
const group = new THREE.Group();
group.add(frontMesh);
group.add(backMesh);
group.add(innerMesh);
scene.add(group);
//Shape code ends here
camera.position.z = 5;
function animate() {
requestAnimationFrame( animate );
group.rotateX(0.01);
group.rotateY(0.01);
renderer.render( scene, camera );
}
animate();
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
Edit
I've tried another method suggested by prisoner849 using conditional line segments here https://discourse.threejs.org/t/ldraw-like-edges/17100. I was able to achieve the same results as method one. Either rendering all the lines or none of the lines on the rounded side, depending on the minimum angle. I feel like I am much closer, but I don't know what I'm missing.
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
const renderer = new THREE.WebGLRenderer( { alpha: true });
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
//Shape code starts here
const w = 1.5;
const h = 1.5;
const r = 0.2;
const d = 0.4;
//Generate the rounded rect shape
const s = new THREE.Shape();
s.moveTo(-w / 2, -h / 2 + r);
s.lineTo(-w / 2, h / 2 - r);
s.absarc(-w / 2 + r, h / 2 - r, r, 1 * Math.PI, 0.5 * Math.PI, true);
s.lineTo(w / 2 - r, h / 2);
s.absarc(w / 2 - r, h / 2 - r, r, 0.5 * Math.PI, 0 * Math.PI, true);
s.lineTo(w / 2, -h / 2 + r);
s.absarc(w / 2 - r, -h / 2 + r, r, 2 * Math.PI, 1.5 * Math.PI, true);
s.lineTo(-w / 2 + r, -h / 2);
s.absarc(-w / 2 + r, -h / 2 + r, r, 1.5 * Math.PI, 1 * Math.PI, true);
//This is the material that is used to stop the
//lines in the back from showing
var innerMat = new THREE.MeshBasicMaterial( {
color: false,
side: THREE.DoubleSide,
depthTest: true,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
} );
//Using the shape, generate the meshes
const outlineMesh = createOutlinedMesh(
new THREE.ExtrudeGeometry(
s,
{
depth: d,
bevelEnabled: false
}
),
'black'
);
const innerMesh = new THREE.Mesh(
new THREE.ExtrudeGeometry(
s,
{
depth: d,
bevelEnabled: false
}
),
innerMat
);
const group = new THREE.Group();
group.add(outlineMesh);
group.add(innerMesh);
scene.add(group);
//Shape code ends here
camera.position.z = 5;
function animate() {
requestAnimationFrame( animate );
group.rotateX(0.01);
group.rotateY(0.01);
renderer.render( scene, camera );
}
animate();
//Added code
function createOutlinedMesh(geometry){
let eg = EdgesGeometry(geometry, 3);
let m = new THREE.ShaderMaterial({
vertexShader: conditionalLineVertShader,
fragmentShader: conditionalLineFragShader,
uniforms: {
diffuse: {
value: 0x000000
},
opacity: {
value: 0
}
},
transparent: false
});
let o = new THREE.LineSegments(eg, m);
// let b = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({
// color: 0x444444,
// map: new THREE.TextureLoader().load("https://threejs.org/examples/textures/uv_grid_opengl.jpg"),
// polygonOffset: true,
// polygonOffsetFactor: 1
// }));
// o.add(b);
return o;
}
var conditionalLineVertShader = /* glsl */`
attribute vec3 control0;
attribute vec3 control1;
attribute vec3 direction;
attribute float collapse;
attribute vec3 instPos;
#include <common>
#include <color_pars_vertex>
#include <fog_pars_vertex>
#include <logdepthbuf_pars_vertex>
#include <clipping_planes_pars_vertex>
void main() {
#include <color_vertex>
// Transform the line segment ends and control points into camera clip space
vec4 c0 = projectionMatrix * modelViewMatrix * vec4( control0 + instPos, 1.0 );
vec4 c1 = projectionMatrix * modelViewMatrix * vec4( control1 + instPos, 1.0 );
vec4 p0 = projectionMatrix * modelViewMatrix * vec4( position + instPos, 1.0 );
vec4 p1 = projectionMatrix * modelViewMatrix * vec4( position + instPos + direction, 1.0 );
c0.xy /= c0.w;
c1.xy /= c1.w;
p0.xy /= p0.w;
p1.xy /= p1.w;
// Get the direction of the segment and an orthogonal vector
vec2 dir = p1.xy - p0.xy;
vec2 norm = vec2( -dir.y, dir.x );
// Get control point directions from the line
vec2 c0dir = c0.xy - p1.xy;
vec2 c1dir = c1.xy - p1.xy;
// If the vectors to the controls points are pointed in different directions away
// from the line segment then the line should not be drawn.
float d0 = dot( normalize( norm ), normalize( c0dir ) );
float d1 = dot( normalize( norm ), normalize( c1dir ) );
float discardFlag = float( sign( d0 ) != sign( d1 ) );
vec3 p = position + instPos + ((discardFlag > 0.5) ? direction * collapse : vec3(0));
vec4 mvPosition = modelViewMatrix * vec4( p, 1.0 );
gl_Position = projectionMatrix * mvPosition;
#include <logdepthbuf_vertex>
#include <clipping_planes_vertex>
#include <fog_vertex>
}
`;
var conditionalLineFragShader = /* glsl */`
uniform vec3 diffuse;
uniform float opacity;
#include <common>
#include <color_pars_fragment>
#include <fog_pars_fragment>
#include <logdepthbuf_pars_fragment>
#include <clipping_planes_pars_fragment>
void main() {
#include <clipping_planes_fragment>
vec3 outgoingLight = vec3( 0.0 );
vec4 diffuseColor = vec4( diffuse, opacity );
#include <logdepthbuf_fragment>
#include <color_fragment>
outgoingLight = diffuseColor.rgb; // simple shader
gl_FragColor = vec4( outgoingLight, diffuseColor.a );
#include <tonemapping_fragment>
#include <encodings_fragment>
#include <fog_fragment>
#include <premultiplied_alpha_fragment>
}
`;
function EdgesGeometry( geometry, thresholdAngle ) {
let g = new THREE.BufferGeometry();
g.type = 'EdgesGeometry';
g.parameters = {
thresholdAngle: thresholdAngle
};
thresholdAngle = ( thresholdAngle !== undefined ) ? thresholdAngle : 1;
// buffer
const vertices = [];
const control0 = [];
const control1 = [];
const direction = [];
const collapse = [];
// helper variables
const thresholdDot = Math.cos( THREE.MathUtils.DEG2RAD * thresholdAngle );
const edge = [ 0, 0 ], edges = {};
let edge1, edge2, key;
const keys = [ 'a', 'b', 'c' ];
// prepare source geometry
let geometry2;
if ( geometry.isBufferGeometry ) {
geometry2 = new THREE.Geometry();
geometry2.fromBufferGeometry( geometry );
} else {
geometry2 = geometry.clone();
}
geometry2.mergeVertices();
geometry2.computeFaceNormals();
const sourceVertices = geometry2.vertices;
const faces = geometry2.faces;
// now create a data structure where each entry represents an edge with its adjoining faces
for ( let i = 0, l = faces.length; i < l; i ++ ) {
const face = faces[ i ];
for ( let j = 0; j < 3; j ++ ) {
edge1 = face[ keys[ j ] ];
edge2 = face[ keys[ ( j + 1 ) % 3 ] ];
edge[ 0 ] = Math.min( edge1, edge2 );
edge[ 1 ] = Math.max( edge1, edge2 );
key = edge[ 0 ] + ',' + edge[ 1 ];
if ( edges[ key ] === undefined ) {
edges[ key ] = { index1: edge[ 0 ], index2: edge[ 1 ], face1: i, face2: undefined };
} else {
edges[ key ].face2 = i;
}
}
}
// generate vertices
const v3 = new THREE.Vector3();
const n = new THREE.Vector3();
const n1 = new THREE.Vector3();
const n2 = new THREE.Vector3();
const d = new THREE.Vector3();
for ( key in edges ) {
const e = edges[ key ];
// an edge is only rendered if the angle (in degrees) between the face normals of the adjoining faces exceeds this value. default = 1 degree.
if ( e.face2 === undefined || faces[ e.face1 ].normal.dot( faces[ e.face2 ].normal ) <= thresholdDot ) {
let vertex1 = sourceVertices[ e.index1 ];
let vertex2 = sourceVertices[ e.index2 ];
vertices.push( vertex1.x, vertex1.y, vertex1.z );
vertices.push( vertex2.x, vertex2.y, vertex2.z );
d.subVectors(vertex2, vertex1);
collapse.push(0, 1);
n.copy(d).normalize();
direction.push(d.x, d.y, d.z);
n1.copy(faces[ e.face1 ].normal);
n1.crossVectors(n, n1);
d.subVectors(vertex1, vertex2);
n.copy(d).normalize();
n2.copy(faces[ e.face2 ].normal);
n2.crossVectors(n, n2);
direction.push(d.x, d.y, d.z);
v3.copy(vertex1).add(n1); // control0
control0.push(v3.x, v3.y, v3.z);
v3.copy(vertex1).add(n2); // control1
control1.push(v3.x, v3.y, v3.z);
v3.copy(vertex2).add(n1); // control0
control0.push(v3.x, v3.y, v3.z);
v3.copy(vertex2).add(n2); // control1
control1.push(v3.x, v3.y, v3.z);
}
}
// build geometry
g.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
g.setAttribute( 'control0', new THREE.Float32BufferAttribute( control0, 3 ) );
g.setAttribute( 'control1', new THREE.Float32BufferAttribute( control1, 3 ) );
g.setAttribute( 'direction', new THREE.Float32BufferAttribute( direction, 3 ) );
g.setAttribute( 'collapse', new THREE.Float32BufferAttribute( collapse, 1 ) );
return g;
}
<script src="https://rawgithub.com/mrdoob/three.js/r118/build/three.js"></script>
<script src="https://rawgithub.com/mrdoob/three.js/r118/examples/js/utils/BufferGeometryUtils.js"></script>




