-
-
Notifications
You must be signed in to change notification settings - Fork 35.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Nodes: Add PixelationNode #28802
Nodes: Add PixelationNode #28802
Changes from 12 commits
b1275d8
1da945a
67c89e0
dd03bf7
43cb319
5648328
57a011f
e0d49da
9269101
4db388a
036af7e
550f063
7788849
63dfe76
58cc81c
c16e316
03f1135
db46ead
09893e9
2ea62ee
cff1d4d
98e4c0e
99a9e23
8742026
e338644
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,290 @@ | ||
<!DOCTYPE html> | ||
<html lang="en"> | ||
<head> | ||
<title>three.js webgpu - postprocessing pixel</title> | ||
<meta charset="utf-8"> | ||
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"> | ||
<link type="text/css" rel="stylesheet" href="main.css"> | ||
</head> | ||
|
||
<body> | ||
<div id="info"> | ||
<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> - Node based pixelation pass with optional single pixel outlines by | ||
<a href="https://github.com/KodyJKing" target="_blank" rel="noopener">Kody King</a><br /><br /> | ||
</div> | ||
|
||
<div id="container"></div> | ||
|
||
<script type="importmap"> | ||
{ | ||
"imports": { | ||
"three": "../build/three.webgpu.js", | ||
"three/tsl": "../build/three.webgpu.js", | ||
"three/addons/": "./jsm/" | ||
} | ||
} | ||
</script> | ||
|
||
|
||
<script type="module"> | ||
|
||
import * as THREE from 'three'; | ||
|
||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | ||
import { GUI } from 'three/addons/libs/lil-gui.module.min.js'; | ||
|
||
import { pass, mrt, output, normalView, uniform, directionToColor } from 'three/tsl'; | ||
|
||
let camera, scene, renderer, postProcessing, crystalMesh, clock; | ||
let gui, effectController; | ||
|
||
init(); | ||
|
||
function init() { | ||
|
||
const aspectRatio = window.innerWidth / window.innerHeight; | ||
|
||
camera = new THREE.OrthographicCamera( - aspectRatio, aspectRatio, 1, - 1, 0.1, 10 ); | ||
camera.position.y = 2 * Math.tan( Math.PI / 6 ); | ||
camera.position.z = 2; | ||
|
||
scene = new THREE.Scene(); | ||
scene.background = new THREE.Color( 0x151729 ); | ||
|
||
clock = new THREE.Clock(); | ||
|
||
// textures | ||
|
||
const loader = new THREE.TextureLoader(); | ||
const texChecker = pixelTexture( loader.load( 'textures/checker.png' ) ); | ||
const texChecker2 = pixelTexture( loader.load( 'textures/checker.png' ) ); | ||
texChecker.repeat.set( 3, 3 ); | ||
texChecker2.repeat.set( 1.5, 1.5 ); | ||
|
||
// meshes | ||
|
||
const boxMaterial = new THREE.MeshPhongMaterial( { map: texChecker2 } ); | ||
|
||
function addBox( boxSideLength, x, z, rotation ) { | ||
|
||
const mesh = new THREE.Mesh( new THREE.BoxGeometry( boxSideLength, boxSideLength, boxSideLength ), boxMaterial ); | ||
mesh.castShadow = false; | ||
mesh.receiveShadow = true; | ||
mesh.rotation.y = rotation; | ||
mesh.position.y = boxSideLength / 2; | ||
mesh.position.set( x, boxSideLength / 2 + .0001, z ); | ||
scene.add( mesh ); | ||
return mesh; | ||
|
||
} | ||
|
||
addBox( .4, 0, 0, Math.PI / 4 ); | ||
addBox( .5, - .5, - .5, Math.PI / 4 ); | ||
|
||
const planeSideLength = 2; | ||
const planeMesh = new THREE.Mesh( | ||
new THREE.PlaneGeometry( planeSideLength, planeSideLength ), | ||
new THREE.MeshPhongMaterial( { map: texChecker } ) | ||
); | ||
planeMesh.receiveShadow = true; | ||
planeMesh.rotation.x = - Math.PI / 2; | ||
scene.add( planeMesh ); | ||
|
||
const radius = .2; | ||
const geometry = new THREE.IcosahedronGeometry( radius ); | ||
crystalMesh = new THREE.Mesh( | ||
geometry, | ||
new THREE.MeshPhongMaterial( { | ||
color: 0x68b7e9, | ||
emissive: 0x4f7e8b, | ||
shininess: 10, | ||
specular: 0xffffff | ||
} ) | ||
); | ||
crystalMesh.receiveShadow = false; | ||
crystalMesh.castShadow = false; | ||
scene.add( crystalMesh ); | ||
|
||
// lights | ||
|
||
const directionalLight = new THREE.DirectionalLight( 0xfffecd, 1.5 ); | ||
directionalLight.position.set( 100, 100, 100 ); | ||
directionalLight.castShadow = true; | ||
directionalLight.shadow.mapSize.set( 2048, 2048 ); | ||
scene.add( directionalLight ); | ||
|
||
const fillLight = new THREE.DirectionalLight( 0x2a3c6b, 1.2 ); | ||
fillLight.position.set(-100, 100, 100); | ||
fillLight.castShadow = true; | ||
fillLight.shadow.mapSize.set( 2048, 2048 ); | ||
scene.add( fillLight ); | ||
|
||
const spotLight = new THREE.SpotLight( 0xffc100, 10, 10, Math.PI / 16, .02, 2 ); | ||
spotLight.position.set( 2, 2, 0 ); | ||
const target = spotLight.target; | ||
scene.add( target ); | ||
target.position.set( 0, 0, 0 ); | ||
spotLight.castShadow = true; | ||
scene.add( spotLight ); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you mind creating the example with the exact same lighting conditions than the original version? It's important to perform a 1:1 comparison so we can see possible deviations. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can apply the same lighting parameters to the scene, but the original lighting can't be replicated due to the shadow artifacts present, as mentioned in #28642. In my testing, recreating the lighting conditions of almost any webgl example that uses standard Three.js lights and casts shadows onto other objects exhibits similar rendering issues when that same sample is ported over to the WebGPURenderer. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Then ignore the shadow casting for now. However, the type of lights and their parametrization should match otherwise the scene's color tone is different which makes it impossible to review the PR. |
||
|
||
renderer = new THREE.WebGPURenderer({ antialias: false }); | ||
renderer.shadowMap.enabled = true; | ||
//renderer.setPixelRatio( window.devicePixelRatio ); | ||
renderer.setSize( window.innerWidth, window.innerHeight ); | ||
renderer.setAnimationLoop( animate ); | ||
document.body.appendChild( renderer.domElement ); | ||
|
||
effectController = { | ||
pixelSize: uniform( 14 ), | ||
normalEdgeStrength: uniform( 0.3 ), | ||
depthEdgeStrength: uniform( 0.4 ), | ||
pixelAlignedPanning: true | ||
}; | ||
|
||
postProcessing = new THREE.PostProcessing( renderer ); | ||
|
||
const scenePass = pass( scene, camera ); | ||
scenePass.setMRT( mrt( { | ||
output: output, | ||
normal: directionToColor(normalView), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be just In the shader, you can then directly use the sampled normal values and don't have to convert them anymore. The render target is of type half float so there is not need to convert to RGB8 and back. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That certainly makes sense to me, although for some reason when testing, the output of directionToColor(normalView) matched the normal output of RenderPixelatedPass while normalView did not. That never seemed right to me though, so I'll go back and check. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've reverted back to normalView, but the effect of normalEdgeStrength is noticeably different/weaker compared to using directionToColor(normalView). I'll add some images to show what I mean, but I believe in this instance, to replicate the effect properly, directionToColor( normalView ) is the correct way our normal pass needs to be configured. WebGL Pixelation tNormal texel output: WebGL Pixelation Output ( Max NormalEdgeStrength) WebGPU Pixelation normalView texel output ( i.e the raw output of the scenePass's normal texture node when the normal render target is set to normalView ): WebGPU Pixelation Output ( Max NormalEdgeStrength with normalView as scenePass normal output ) WebGPU Pixelation directionToColor( normalView ) texel output WebGPU Pixelation Output ( Max NormalEdgeStrength with directionToColor( normalView ) as scenePass normal output ) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are roughly analagous to the same effects I noticed before, which is why I ultimately chose directionToColor( normalView ) over normalView, even if intuition would lead us to use normalView. |
||
} ) ); | ||
|
||
const depthNode = scenePass.getTextureNode( 'depth' ); | ||
const normalNode = scenePass.getTextureNode( 'normal' ); | ||
postProcessing.outputNode = scenePass.getTextureNode('output').pixelation( depthNode, normalNode, effectController.pixelSize, effectController.normalEdgeStrength, effectController.depthEdgeStrength ); | ||
|
||
window.addEventListener( 'resize', onWindowResize ); | ||
|
||
const controls = new OrbitControls( camera, renderer.domElement ); | ||
controls.maxZoom = 2; | ||
|
||
// gui | ||
|
||
gui = new GUI(); | ||
gui.add( effectController.pixelSize, 'value', 1, 14, 1 ).name( 'Pixel Size' ); | ||
gui.add( effectController.normalEdgeStrength, 'value', 0, 2, 0.05 ).name( 'Normal Edge Strength' ); | ||
gui.add( effectController.depthEdgeStrength, 'value', 0, 1, 0.05 ).name( 'Depth Edge Strength' ); | ||
gui.add( effectController, 'pixelAlignedPanning' ); | ||
|
||
|
||
} | ||
|
||
function onWindowResize() { | ||
|
||
const aspectRatio = window.innerWidth / window.innerHeight; | ||
camera.left = - aspectRatio; | ||
camera.right = aspectRatio; | ||
camera.updateProjectionMatrix(); | ||
|
||
renderer.setSize( window.innerWidth, window.innerHeight ); | ||
|
||
} | ||
|
||
function animate() { | ||
|
||
const t = clock.getElapsedTime(); | ||
|
||
crystalMesh.material.emissiveIntensity = Math.sin( t * 3 ) * .5 + .5; | ||
crystalMesh.position.y = .7 + Math.sin( t * 2 ) * .05; | ||
crystalMesh.rotation.y = stopGoEased( t, 2, 4 ) * 2 * Math.PI; | ||
|
||
const rendererSize = renderer.getSize( new THREE.Vector2() ); | ||
const aspectRatio = rendererSize.x / rendererSize.y; | ||
|
||
if ( effectController.pixelAlignedPanning ) { | ||
|
||
const pixelSize = effectController.pixelSize.value; | ||
|
||
pixelAlignFrustum( camera, aspectRatio, Math.floor( rendererSize.x / pixelSize ), | ||
Math.floor( rendererSize.y / pixelSize ) ); | ||
|
||
} else if ( camera.left != - aspectRatio || camera.top != 1.0 ) { | ||
|
||
// Reset the Camera Frustum if it has been modified | ||
camera.left = - aspectRatio; | ||
camera.right = aspectRatio; | ||
camera.top = 1.0; | ||
camera.bottom = - 1.0; | ||
camera.updateProjectionMatrix(); | ||
|
||
} | ||
|
||
postProcessing.render(); | ||
|
||
} | ||
|
||
// Helper functions | ||
|
||
function pixelTexture( texture ) { | ||
|
||
texture.minFilter = THREE.NearestFilter; | ||
texture.magFilter = THREE.NearestFilter; | ||
texture.generateMipmaps = false; | ||
texture.wrapS = THREE.RepeatWrapping; | ||
texture.wrapT = THREE.RepeatWrapping; | ||
texture.colorSpace = THREE.SRGBColorSpace; | ||
return texture; | ||
|
||
} | ||
|
||
function easeInOutCubic( x ) { | ||
|
||
return x ** 2 * 3 - x ** 3 * 2; | ||
|
||
} | ||
|
||
function linearStep( x, edge0, edge1 ) { | ||
|
||
const w = edge1 - edge0; | ||
const m = 1 / w; | ||
const y0 = - m * edge0; | ||
return THREE.MathUtils.clamp( y0 + m * x, 0, 1 ); | ||
|
||
} | ||
|
||
function stopGoEased( x, downtime, period ) { | ||
|
||
const cycle = ( x / period ) | 0; | ||
const tween = x - cycle * period; | ||
const linStep = easeInOutCubic( linearStep( tween, downtime, period ) ); | ||
return cycle + linStep; | ||
|
||
} | ||
|
||
function pixelAlignFrustum( camera, aspectRatio, pixelsPerScreenWidth, pixelsPerScreenHeight ) { | ||
|
||
|
||
// 0. Get Pixel Grid Units | ||
const worldScreenWidth = ( ( camera.right - camera.left ) / camera.zoom ); | ||
const worldScreenHeight = ( ( camera.top - camera.bottom ) / camera.zoom ); | ||
const pixelWidth = worldScreenWidth / pixelsPerScreenWidth; | ||
const pixelHeight = worldScreenHeight / pixelsPerScreenHeight; | ||
|
||
// 1. Project the current camera position along its local rotation bases | ||
const camPos = new THREE.Vector3(); camera.getWorldPosition( camPos ); | ||
const camRot = new THREE.Quaternion(); camera.getWorldQuaternion( camRot ); | ||
const camRight = new THREE.Vector3( 1.0, 0.0, 0.0 ).applyQuaternion( camRot ); | ||
const camUp = new THREE.Vector3( 0.0, 1.0, 0.0 ).applyQuaternion( camRot ); | ||
const camPosRight = camPos.dot( camRight ); | ||
const camPosUp = camPos.dot( camUp ); | ||
|
||
// 2. Find how far along its position is along these bases in pixel units | ||
const camPosRightPx = camPosRight / pixelWidth; | ||
const camPosUpPx = camPosUp / pixelHeight; | ||
|
||
// 3. Find the fractional pixel units and convert to world units | ||
const fractX = camPosRightPx - Math.round( camPosRightPx ); | ||
const fractY = camPosUpPx - Math.round( camPosUpPx ); | ||
|
||
// 4. Add fractional world units to the left/right top/bottom to align with the pixel grid | ||
camera.left = - aspectRatio - ( fractX * pixelWidth ); | ||
camera.right = aspectRatio - ( fractX * pixelWidth ); | ||
camera.top = 1.0 - ( fractY * pixelHeight ); | ||
camera.bottom = - 1.0 - ( fractY * pixelHeight ); | ||
camera.updateProjectionMatrix(); | ||
|
||
} | ||
|
||
</script> | ||
</body> | ||
|
||
</html> |
Check notice
Code scanning / CodeQL
Unused variable, import, function or class Note