diff --git a/.storybook/stories/Outlines.stories.ts b/.storybook/stories/Outlines.stories.ts index 40a1f18..5c55788 100644 --- a/.storybook/stories/Outlines.stories.ts +++ b/.storybook/stories/Outlines.stories.ts @@ -16,16 +16,19 @@ let allOutlines: OutlinesType[] = [] const outlinesParams = { color: '#ffff00' as THREE.ColorRepresentation, thickness: 0.1, + screenspace: false, } -const generateOutlines = () => { +const generateOutlines = (gl: THREE.WebGLRenderer) => { return Outlines({ color: new THREE.Color(outlinesParams.color), thickness: outlinesParams.thickness, + screenspace: outlinesParams.screenspace, + gl, }) } -const setupTourMesh = () => { +const setupTourMesh = (gl: THREE.WebGLRenderer) => { const geometry = new THREE.TorusKnotGeometry(1, 0.35, 100, 32) const mat = new THREE.MeshStandardMaterial({ roughness: 0, @@ -33,7 +36,7 @@ const setupTourMesh = () => { }) const torusMesh = new THREE.Mesh(geometry, mat) - const outlines = generateOutlines() + const outlines = generateOutlines(gl) torusMesh.traverse((child) => { if (child instanceof THREE.Mesh) { child.castShadow = true @@ -47,12 +50,12 @@ const setupTourMesh = () => { return torusMesh } -const setupBox = () => { +const setupBox = (gl: THREE.WebGLRenderer) => { const geometry = new THREE.BoxGeometry(2, 2, 2) const mat = new THREE.MeshBasicMaterial({ color: 'grey' }) const boxMesh = new THREE.Mesh(geometry, mat) boxMesh.position.y = 1.2 - const outlines = generateOutlines() + const outlines = generateOutlines(gl) allOutlines.push(outlines) boxMesh.add(outlines.group) @@ -88,9 +91,9 @@ export const OutlinesStory = async () => { camera.position.set(10, 10, 10) scene.add(setupLight()) - scene.add(setupTourMesh()) + scene.add(setupTourMesh(renderer)) - const box = setupBox() + const box = setupBox(renderer) scene.add(box) const floor = new THREE.Mesh( @@ -118,9 +121,14 @@ const addOutlineGui = () => { outline.updateProps({ color: new THREE.Color(color) }) }) }) - folder.add(params, 'thickness', 0, 0.1, 0.01).onChange((thickness: number) => { + folder.add(params, 'thickness', 0, 2, 0.01).onChange((thickness: number) => { allOutlines.forEach((outline) => { outline.updateProps({ thickness }) }) }) + folder.add(params, 'screenspace').onChange((screenspace: boolean) => { + allOutlines.forEach((outline) => { + outline.updateProps({ screenspace }) + }) + }) } diff --git a/README.md b/README.md index b78e908..0a8c241 100644 --- a/README.md +++ b/README.md @@ -511,15 +511,23 @@ An ornamental component that extracts the geometry from its parent and displays ```tsx export type OutlinesProps = { /** Outline color, default: black */ - color: THREE.Color + color?: THREE.Color + /** Line thickness is independent of zoom, default: false */ + screenspace?: boolean /** Outline opacity, default: 1 */ - opacity: number + opacity?: number /** Outline transparency, default: false */ - transparent: boolean + transparent?: boolean /** Outline thickness, default 0.05 */ - thickness: number + thickness?: number /** Geometry crease angle (0 === no crease), default: Math.PI */ - angle: number + angle?: number + toneMapped?: boolean + polygonOffset?: boolean + polygonOffsetFactor?: number + renderOrder?: number + /** needed if `screenspace` is true */ + gl?: THREE.WebGLRenderer } ``` diff --git a/src/core/Outlines.ts b/src/core/Outlines.ts index 3bc0647..221c05b 100644 --- a/src/core/Outlines.ts +++ b/src/core/Outlines.ts @@ -1,18 +1,26 @@ +import { shaderMaterial } from './shaderMaterial' import * as THREE from 'three' import { toCreasedNormals } from 'three/examples/jsm/utils/BufferGeometryUtils' -import { shaderMaterial } from './shaderMaterial' export type OutlinesProps = { /** Outline color, default: black */ - color: THREE.Color + color?: THREE.Color + /** Line thickness is independent of zoom, default: false */ + screenspace?: boolean /** Outline opacity, default: 1 */ - opacity: number + opacity?: number /** Outline transparency, default: false */ - transparent: boolean + transparent?: boolean /** Outline thickness, default 0.05 */ - thickness: number + thickness?: number /** Geometry crease angle (0 === no crease), default: Math.PI */ - angle: number + angle?: number + toneMapped?: boolean + polygonOffset?: boolean + polygonOffsetFactor?: number + renderOrder?: number + /** needed if `screenspace` is true */ + gl?: THREE.WebGLRenderer } export type OutlinesType = { @@ -25,12 +33,20 @@ export type OutlinesType = { } const OutlinesMaterial = shaderMaterial( - { color: new THREE.Color('black'), opacity: 1, thickness: 0.05 }, + { + screenspace: false, + color: new THREE.Color('black'), + opacity: 1, + thickness: 0.05, + size: new THREE.Vector2(), + }, /* glsl */ ` #include #include #include uniform float thickness; + uniform float screenspace; + uniform vec2 size; void main() { #if defined (USE_SKINNING) #include @@ -43,14 +59,22 @@ const OutlinesMaterial = shaderMaterial( #include #include #include - vec4 transformedNormal = vec4(normal, 0.0); - vec4 transformedPosition = vec4(transformed, 1.0); + vec4 tNormal = vec4(normal, 0.0); + vec4 tPosition = vec4(transformed, 1.0); #ifdef USE_INSTANCING - transformedNormal = instanceMatrix * transformedNormal; - transformedPosition = instanceMatrix * transformedPosition; + tNormal = instanceMatrix * tNormal; + tPosition = instanceMatrix * tPosition; #endif - vec3 newPosition = transformedPosition.xyz + transformedNormal.xyz * thickness; - gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); + if (screenspace == 0.0) { + vec3 newPosition = tPosition.xyz + tNormal.xyz * thickness; + gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0); + } else { + vec4 clipPosition = projectionMatrix * modelViewMatrix * tPosition; + vec4 clipNormal = projectionMatrix * modelViewMatrix * tNormal; + vec2 offset = normalize(clipNormal.xy) * thickness / size * clipPosition.w * 2.0; + clipPosition.xy += offset; + gl_Position = clipPosition; + } }`, /* glsl */ ` uniform vec3 color; @@ -66,8 +90,14 @@ export function Outlines({ color = new THREE.Color('black'), opacity = 1, transparent = false, + screenspace = false, + toneMapped = true, + polygonOffset = false, + polygonOffsetFactor = 0, + renderOrder = 0, thickness = 0.05, angle = Math.PI, + gl, }: Partial): OutlinesType { const group = new THREE.Group() @@ -75,27 +105,33 @@ export function Outlines({ color, opacity, transparent, + screenspace, + toneMapped, + polygonOffset, + polygonOffsetFactor, + renderOrder, thickness, angle, } - function updateMesh(angle: number) { + function updateMesh(angle?: number) { const parent = group.parent as THREE.Mesh & THREE.SkinnedMesh & THREE.InstancedMesh group.clear() if (parent && parent.geometry) { let mesh + const material = new OutlinesMaterial({ side: THREE.BackSide }) if (parent.skeleton) { mesh = new THREE.SkinnedMesh() - mesh.material = new OutlinesMaterial({ side: THREE.BackSide }) + mesh.material = material mesh.bind(parent.skeleton, parent.bindMatrix) group.add(mesh) } else if (parent.isInstancedMesh) { - mesh = new THREE.InstancedMesh(parent.geometry, new OutlinesMaterial({ side: THREE.BackSide }), parent.count) + mesh = new THREE.InstancedMesh(parent.geometry, material, parent.count) mesh.instanceMatrix = parent.instanceMatrix group.add(mesh) } else { mesh = new THREE.Mesh() - mesh.material = new OutlinesMaterial({ side: THREE.BackSide }) + mesh.material = material group.add(mesh) } mesh.geometry = angle ? toCreasedNormals(parent.geometry, angle) : parent.geometry @@ -106,8 +142,35 @@ export function Outlines({ shapeProps = { ...shapeProps, ...newProps } const mesh = group.children[0] as THREE.Mesh if (mesh) { - const { transparent, thickness, color, opacity } = shapeProps - Object.assign(mesh.material, { transparent, thickness, color, opacity }) + const { + transparent, + thickness, + color, + opacity, + screenspace, + toneMapped, + polygonOffset, + polygonOffsetFactor, + renderOrder, + } = shapeProps + const contextSize = new THREE.Vector2() + if (!gl && shapeProps.screenspace) { + console.warn('Outlines: "screenspace" requires a WebGLRenderer instance to calculate the outline size') + } + if (gl) gl.getSize(contextSize) + + Object.assign(mesh.material, { + transparent, + thickness, + color, + opacity, + size: contextSize, + screenspace, + toneMapped, + polygonOffset, + polygonOffsetFactor, + }) + if (renderOrder !== undefined) mesh.renderOrder = renderOrder } }