diff --git a/examples/files.json b/examples/files.json index e704fd87b33b47..6c28f7775ae399 100644 --- a/examples/files.json +++ b/examples/files.json @@ -443,6 +443,7 @@ "webgpu_postprocessing_difference", "webgpu_postprocessing_dof", "webgpu_postprocessing_dof_basic", + "webgpu_postprocessing_fog", "webgpu_postprocessing_fxaa", "webgpu_postprocessing_godrays", "webgpu_postprocessing_lensflare", diff --git a/examples/jsm/tsl/display/GaussianBlurNode.js b/examples/jsm/tsl/display/GaussianBlurNode.js index b9f13b1c7cf18a..a45e35dd90670e 100644 --- a/examples/jsm/tsl/display/GaussianBlurNode.js +++ b/examples/jsm/tsl/display/GaussianBlurNode.js @@ -1,5 +1,5 @@ -import { RenderTarget, Vector2, NodeMaterial, RendererUtils, QuadMesh, TempNode, NodeUpdateType } from 'three/webgpu'; -import { Fn, float, uv, uniform, convertToTexture, vec2, vec4, passTexture, premultiplyAlpha, unpremultiplyAlpha, context } from 'three/tsl'; +import { RenderTarget, Vector2, NodeMaterial, RendererUtils, QuadMesh, TempNode, NodeUpdateType, warnOnce } from 'three/webgpu'; +import { Fn, float, uv, uniform, convertToTexture, vec2, vec4, passTexture, premultiplyAlpha, unpremultiplyAlpha, context, texture } from 'three/tsl'; const _quadMesh = /*@__PURE__*/ new QuadMesh(); @@ -62,15 +62,6 @@ class GaussianBlurNode extends TempNode { */ this._invSize = uniform( new Vector2() ); - /** - * Gaussian blur is applied in two passes (horizontal, vertical). - * This node controls the direction of each pass. - * - * @private - * @type {UniformNode} - */ - this._passDirection = uniform( new Vector2() ); - /** * The render target used for the horizontal pass. * @@ -104,7 +95,15 @@ class GaussianBlurNode extends TempNode { * @private * @type {?NodeMaterial} */ - this._material = null; + this._hMaterial = null; + + /** + * The material for the vertical pass. + * + * @private + * @type {?NodeMaterial} + */ + this._vMaterial = null; /** * The `updateBeforeType` is set to `NodeUpdateType.FRAME` since the node renders @@ -173,12 +172,9 @@ class GaussianBlurNode extends TempNode { // - const textureNode = this.textureNode; - const map = textureNode.value; + const map = this.textureNode.value; - const currentTexture = textureNode.value; - - _quadMesh.material = this._material; + _quadMesh.material = this._hMaterial; this.setSize( map.image.width, map.image.height ); @@ -191,25 +187,20 @@ class GaussianBlurNode extends TempNode { renderer.setRenderTarget( this._horizontalRT ); - this._passDirection.value.set( 1, 0 ); - _quadMesh.name = 'Gaussian Blur [ Horizontal Pass ]'; _quadMesh.render( renderer ); // vertical - textureNode.value = this._horizontalRT.texture; - renderer.setRenderTarget( this._verticalRT ); + _quadMesh.material = this._vMaterial; - this._passDirection.value.set( 0, 1 ); + renderer.setRenderTarget( this._verticalRT ); _quadMesh.name = 'Gaussian Blur [ Vertical Pass ]'; _quadMesh.render( renderer ); // restore - textureNode.value = currentTexture; - RendererUtils.restoreRendererState( renderer, _rendererState ); } @@ -233,36 +224,32 @@ class GaussianBlurNode extends TempNode { */ setup( builder ) { - const textureNode = this.textureNode; - - // - const uvNode = uv(); const directionNode = vec2( this.directionNode || 1 ); - let sampleTexture, output; + const blur = Fn( ( [ textureNode, passDirection ] ) => { - if ( this.premultipliedAlpha ) { + let sampleTexture, output; - // https://lisyarus.github.io/blog/posts/blur-coefficients-generator.html + if ( this.premultipliedAlpha ) { - sampleTexture = ( uv ) => premultiplyAlpha( textureNode.sample( uv ) ); - output = ( color ) => unpremultiplyAlpha( color ); + // https://lisyarus.github.io/blog/posts/blur-coefficients-generator.html - } else { + sampleTexture = ( uv ) => premultiplyAlpha( textureNode.sample( uv ) ); + output = ( color ) => unpremultiplyAlpha( color ); - sampleTexture = ( uv ) => textureNode.sample( uv ); - output = ( color ) => color; + } else { - } + sampleTexture = ( uv ) => textureNode.sample( uv ); + output = ( color ) => color; - const blur = Fn( () => { + } const kernelSize = 3 + ( 2 * this.sigma ); const gaussianCoefficients = this._getCoefficients( kernelSize ); const invSize = this._invSize; - const direction = directionNode.mul( this._passDirection ); + const direction = directionNode.mul( passDirection ); const diffuseSum = vec4( sampleTexture( uvNode ).mul( gaussianCoefficients[ 0 ] ) ).toVar(); @@ -286,16 +273,26 @@ class GaussianBlurNode extends TempNode { // - const material = this._material || ( this._material = new NodeMaterial() ); - material.contextNode = context( builder.getSharedContext() ); - material.fragmentNode = blur(); - material.name = 'Gaussian_blur'; - material.needsUpdate = true; + const hTextureNode = this.textureNode; + + this._hMaterial = this._hMaterial || ( new NodeMaterial() ); + this._hMaterial.contextNode = context( builder.getSharedContext() ); + this._hMaterial.fragmentNode = blur( hTextureNode, vec2( 1, 0 ) ); + this._hMaterial.name = 'Gaussian_blur_horizontal'; + this._hMaterial.needsUpdate = true; + + const vTextureNode = texture( this._horizontalRT.texture, uv() ); + + this._vMaterial = this._vMaterial || new NodeMaterial(); + this._vMaterial.fragmentNode = blur( vTextureNode, vec2( 0, 1 ) ); + this._vMaterial.name = 'Gaussian_blur_vertical'; + this._vMaterial.needsUpdate = true; // const properties = builder.getNodeProperties( this ); - properties.textureNode = textureNode; + properties.hTextureNode = hTextureNode; + properties.vTextureNode = vTextureNode; // @@ -312,7 +309,17 @@ class GaussianBlurNode extends TempNode { this._horizontalRT.dispose(); this._verticalRT.dispose(); - if ( this._material !== null ) this._material.dispose(); + if ( this._hMaterial !== null ) { + + this._hMaterial.dispose(); + this._vMaterial.dispose(); + + this._hMaterial = null; + this._vMaterial = null; + + } + + super.dispose(); } @@ -328,13 +335,18 @@ class GaussianBlurNode extends TempNode { const coefficients = []; const sigma = kernelRadius / 3; - for ( let i = 0; i < kernelRadius; i ++ ) { + let sum = 1; + coefficients.push( 1 ); + + for ( let i = 1; i < kernelRadius; i ++ ) { - coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( sigma * sigma ) ) / sigma ); + const w = Math.exp( - 0.5 * i * i / ( sigma * sigma ) ); + coefficients.push( w ); + sum += 2 * w; } - return coefficients; + return coefficients.map( c => c / sum ); } @@ -347,7 +359,7 @@ class GaussianBlurNode extends TempNode { */ get resolution() { - console.warn( 'THREE.GaussianBlurNode: The "resolution" property has been renamed to "resolutionScale" and is now of type `number`.' ); // @deprecated r180 + warnOnce( 'THREE.GaussianBlurNode: The "resolution" property has been renamed to "resolutionScale" and is now of type `number`.' ); // @deprecated r180 return new Vector2( this.resolutionScale, this.resolutionScale ); @@ -355,7 +367,7 @@ class GaussianBlurNode extends TempNode { set resolution( value ) { - console.warn( 'THREE.GaussianBlurNode: The "resolution" property has been renamed to "resolutionScale" and is now of type `number`.' ); // @deprecated r180 + warnOnce( 'THREE.GaussianBlurNode: The "resolution" property has been renamed to "resolutionScale" and is now of type `number`.' ); // @deprecated r180 this.resolutionScale = value.x; @@ -385,7 +397,7 @@ export const gaussianBlur = ( node, directionNode, sigma, options = {} ) => new * * @tsl * @function - * @deprecated since r180. Use `gaussianBlur()` with `premultipliedAlpha: true` option instead. + * @deprecated since r180. Use `gaussianBlur()` with `premultipliedAlpha: true` option instead. * @param {Node} node - The node that represents the input of the effect. * @param {Node} directionNode - Defines the direction and radius of the blur. * @param {number} sigma - Controls the kernel of the blur filter. Higher values mean a wider blur radius. @@ -393,7 +405,7 @@ export const gaussianBlur = ( node, directionNode, sigma, options = {} ) => new */ export function premultipliedGaussianBlur( node, directionNode, sigma ) { - console.warn( 'THREE.TSL: "premultipliedGaussianBlur()" is deprecated. Use "gaussianBlur()" with "premultipliedAlpha: true" option instead.' ); // deprecated, r180 + warnOnce( 'THREE.TSL: "premultipliedGaussianBlur()" is deprecated. Use "gaussianBlur()" with "premultipliedAlpha: true" option instead.' ); // @deprecated r180 return gaussianBlur( node, directionNode, sigma, { premultipliedAlpha: true } ); diff --git a/examples/screenshots/webgpu_postprocessing_fog.jpg b/examples/screenshots/webgpu_postprocessing_fog.jpg new file mode 100644 index 00000000000000..8b1e3671261e24 Binary files /dev/null and b/examples/screenshots/webgpu_postprocessing_fog.jpg differ diff --git a/examples/tags.json b/examples/tags.json index 60d8e977a47f35..a4c8569cd47e31 100644 --- a/examples/tags.json +++ b/examples/tags.json @@ -30,8 +30,8 @@ "webgl_geometries": [ "geometry" ], "webgl_geometry_colors_lookuptable": [ "vertex" ], "webgl_geometry_csg": [ "community", "csg", "bvh", "constructive", "solid", "geometry", "games", "level" ], - "webgpu_geometry_loft": [ "sweep", "skin", "sections", "surface", "tsl", "procedural" ], - "webgpu_gaussian_splat": [ "splatting", "point cloud", "loader", "compute", "tsl" ], + "webgpu_geometry_loft": [ "sweep", "skin", "sections", "surface", "procedural" ], + "webgpu_gaussian_splat": [ "splatting", "point cloud", "loader", "compute" ], "webgl_geometry_nurbs": [ "curve", "surface" ], "webgl_geometry_spline_editor": [ "curve" ], "webgl_geometry_terrain": [ "fog" ], @@ -178,5 +178,6 @@ "webgpu_ocean": [ "water" ], "webgpu_video_frame": [ "webcodecs" ], "webgpu_shadowmap_array": [ "tile" ], - "webgpu_shadow_contact": [ "shadow", "soft", "tsl" ] + "webgpu_shadow_contact": [ "shadow", "soft" ], + "webgpu_postprocessing_fog": [ "fog", "volumetric", "jbu", "gaussian", "raymarching" ] } diff --git a/examples/webgpu_postprocessing_fog.html b/examples/webgpu_postprocessing_fog.html new file mode 100644 index 00000000000000..3d019bb231e16b --- /dev/null +++ b/examples/webgpu_postprocessing_fog.html @@ -0,0 +1,595 @@ + + + + three.js webgpu - post-processing fog + + + + + + + + + + +
+ + +
+ three.jsVolumetric Fog +
+ + + Post-Processing Volumetric Cloud Fog using Depth Reconstruction, JBU & Gaussian Filtering in TSL. + +
+ + + + + + diff --git a/src/nodes/utils/RTTNode.js b/src/nodes/utils/RTTNode.js index 487f9651dbe76e..8b1a4952f50a06 100644 --- a/src/nodes/utils/RTTNode.js +++ b/src/nodes/utils/RTTNode.js @@ -9,6 +9,7 @@ import QuadMesh from '../../renderers/common/QuadMesh.js'; import { RenderTarget } from '../../core/RenderTarget.js'; import { Vector2 } from '../../math/Vector2.js'; import { HalfFloatType } from '../../constants.js'; +import { error } from '../../utils.js'; const _size = /*@__PURE__*/ new Vector2(); @@ -32,7 +33,7 @@ class RTTNode extends TextureNode { * Constructs a new RTT node. * * @param {Node} node - The node to render a texture with. - * @param {?number} [width=null] - The width of the internal render target. If not width is applied, the render target is automatically resized. + * @param {?number} [width=null] - The width of the internal render target. If no width is applied, the render target is automatically resized. * @param {?number} [height=null] - The height of the internal render target. * @param {Object} [options={}] - The options for the internal render target. * @param {number} [options.type=HalfFloatType] - The texture type. @@ -124,13 +125,13 @@ class RTTNode extends TextureNode { this._quadMesh = new QuadMesh( new NodeMaterial() ); /** - * The `updateBeforeType` is set to `NodeUpdateType.RENDER` since the node updates - * the texture once per render in its {@link RTTNode#updateBefore} method. + * The `updateBeforeType` is set to `NodeUpdateType.FRAME` since the node updates + * the texture once per frame in its {@link RTTNode#updateBefore} method. * * @type {string} - * @default 'render' + * @default 'frame' */ - this.updateBeforeType = NodeUpdateType.RENDER; + this.updateBeforeType = NodeUpdateType.FRAME; } @@ -159,10 +160,10 @@ class RTTNode extends TextureNode { } /** - * Sets the size of the internal render target + * Sets the size of the internal render target. * * @param {number} width - The width to set. - * @param {number} height - The width to set. + * @param {number} height - The height to set. */ setSize( width, height ) { @@ -207,7 +208,42 @@ class RTTNode extends TextureNode { } - updateBefore( { renderer } ) { + /** + * Overwritten since the value is defined by the internal render target. + * + * @param {Texture} value - The texture value. + */ + set value( value ) { + + if ( this.renderTarget && value !== this.renderTarget.texture ) { + + error( 'TSL: "rtt()" does not allow overwriting the value.' ); + + } + + } + + /** + * The texture of the internal render target. + * + * @type {Texture} + */ + get value() { + + return this.renderTarget ? this.renderTarget.texture : null; + + } + + /** + * Renders the node's output into the internal render target before the main render pass. + * Handles automatic resizing of the render target when `autoResize` is enabled, + * and skips rendering if neither `textureNeedsUpdate` nor `autoUpdate` is true. + * + * @param {NodeFrame} frame - The current node frame, providing access to the renderer and other frame data. + */ + updateBefore( frame ) { + + const { renderer } = frame; if ( this.textureNeedsUpdate === false && this.autoUpdate === false ) return; @@ -238,9 +274,11 @@ class RTTNode extends TextureNode { let name = 'RTT'; - if ( this.node.name ) { + const callName = this.name || this.node.name; + + if ( callName ) { - name = this.node.name + ' [ ' + name + ' ]'; + name = callName + ' [ ' + name + ' ]'; } @@ -266,6 +304,18 @@ class RTTNode extends TextureNode { } + /** + * Frees internal resources. Should be called when the node is no longer in use. + */ + dispose() { + + this.renderTarget.dispose(); + this._quadMesh.material.dispose(); + + super.dispose(); + + } + } export default RTTNode; @@ -276,7 +326,7 @@ export default RTTNode; * @tsl * @function * @param {Node} node - The node to render a texture with. - * @param {?number} [width=null] - The width of the internal render target. If not width is applied, the render target is automatically resized. + * @param {?number} [width=null] - The width of the internal render target. If no width is applied, the render target is automatically resized. * @param {?number} [height=null] - The height of the internal render target. * @param {Object} [options={}] - The options for the internal render target. * @param {number} [options.type=HalfFloatType] - The texture type. @@ -292,7 +342,7 @@ export const rtt = ( node, ...params ) => new RTTNode( nodeObject( node ), ...pa * @tsl * @function * @param {Node} node - The node to render a texture with. - * @param {?number} [width=null] - The width of the internal render target. If not width is applied, the render target is automatically resized. + * @param {?number} [width=null] - The width of the internal render target. If no width is applied, the render target is automatically resized. * @param {?number} [height=null] - The height of the internal render target. * @param {Object} [options={}] - The options for the internal render target. * @param {number} [options.type=HalfFloatType] - The texture type. diff --git a/src/nodes/utils/RotateNode.js b/src/nodes/utils/RotateNode.js index e42182302a3258..706b2d5d7eb2a9 100644 --- a/src/nodes/utils/RotateNode.js +++ b/src/nodes/utils/RotateNode.js @@ -1,6 +1,7 @@ import TempNode from '../core/TempNode.js'; import { nodeProxy, vec4, mat2, mat4 } from '../tsl/TSLBase.js'; import { cos, sin } from '../math/MathNode.js'; +import { hashString } from '../core/NodeUtils.js'; /** * Applies a rotation to the given position node. @@ -21,8 +22,9 @@ class RotateNode extends TempNode { * @param {Node} positionNode - The position node. * @param {Node} rotationNode - Represents the rotation that is applied to the position node. Depending * on whether the position data are 2D or 3D, the rotation is expressed a single float value or an Euler value. + * @param {string} [order='XYZ'] - The Euler rotation order. Only used for 3D rotation. */ - constructor( positionNode, rotationNode ) { + constructor( positionNode, rotationNode, order = 'XYZ' ) { super(); @@ -34,13 +36,59 @@ class RotateNode extends TempNode { this.positionNode = positionNode; /** - * Represents the rotation that is applied to the position node. - * Depending on whether the position data are 2D or 3D, the rotation is expressed a single float value or an Euler value. + * Represents the rotation that is applied to the position node. + * Depending on whether the position data are 2D or 3D, the rotation is expressed a single float value or an Euler value. * * @type {Node} */ this.rotationNode = rotationNode; + /** + * The Euler rotation order. + * + * @private + * @type {string} + * @default 'XYZ' + */ + this._order = order; + + } + + /** + * Overwrites the default `customCacheKey()` implementation by including the + * Euler order into the cache key. + * + * @return {number} The hash. + */ + customCacheKey() { + + return hashString( this._order ); + + } + + /** + * Sets the Euler rotation order. + * + * @param {string} value - The Euler rotation order. + * @return {RotateNode} A reference to this node. + */ + setOrder( value ) { + + this._order = value; + + return this; + + } + + /** + * Gets the Euler rotation order. + * + * @return {string} The Euler rotation order. + */ + getOrder() { + + return this._order; + } /** @@ -76,16 +124,44 @@ class RotateNode extends TempNode { } else { const rotation = rotationNode; + const order = this._order; + const rotationXMatrix = mat4( vec4( 1.0, 0.0, 0.0, 0.0 ), vec4( 0.0, cos( rotation.x ), sin( rotation.x ), 0.0 ), vec4( 0.0, sin( rotation.x ).negate(), cos( rotation.x ), 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); const rotationYMatrix = mat4( vec4( cos( rotation.y ), 0.0, sin( rotation.y ).negate(), 0.0 ), vec4( 0.0, 1.0, 0.0, 0.0 ), vec4( sin( rotation.y ), 0.0, cos( rotation.y ), 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); const rotationZMatrix = mat4( vec4( cos( rotation.z ), sin( rotation.z ), 0.0, 0.0 ), vec4( sin( rotation.z ).negate(), cos( rotation.z ), 0.0, 0.0 ), vec4( 0.0, 0.0, 1.0, 0.0 ), vec4( 0.0, 0.0, 0.0, 1.0 ) ); - return rotationXMatrix.mul( rotationYMatrix ).mul( rotationZMatrix ).mul( vec4( positionNode, 1.0 ) ).xyz; + const matrixMap = { + 'X': rotationXMatrix, + 'Y': rotationYMatrix, + 'Z': rotationZMatrix + }; + + const matrixChain = matrixMap[ order.charAt( 0 ) ] + .mul( matrixMap[ order.charAt( 1 ) ] ) + .mul( matrixMap[ order.charAt( 2 ) ] ); + + return matrixChain.mul( vec4( positionNode, 1.0 ) ).xyz; } } + serialize( data ) { + + super.serialize( data ); + + data.order = this._order; + + } + + deserialize( data ) { + + super.deserialize( data ); + + this._order = data.order; + + } + } export default RotateNode; @@ -98,6 +174,7 @@ export default RotateNode; * @param {Node} positionNode - The position node. * @param {Node} rotationNode - Represents the rotation that is applied to the position node. Depending * on whether the position data are 2D or 3D, the rotation is expressed a single float value or an Euler value. + * @param {string} [order='XYZ'] - The Euler rotation order. Only used for 3D rotation. * @returns {RotateNode} */ -export const rotate = /*@__PURE__*/ nodeProxy( RotateNode ).setParameterLength( 2 ); +export const rotate = /*@__PURE__*/ nodeProxy( RotateNode ).setParameterLength( 2, 3 ); diff --git a/test/unit/addons/tsl/TSLRotate.tests.js b/test/unit/addons/tsl/TSLRotate.tests.js index d59dd7fa4b04c9..e2064763171141 100644 --- a/test/unit/addons/tsl/TSLRotate.tests.js +++ b/test/unit/addons/tsl/TSLRotate.tests.js @@ -14,8 +14,8 @@ export default QUnit.module( 'TSL', () => { assert.closeAbs( rotated90, vec2( 0, 1 ), 1e-4, 'rotate((1,0), PI/2) == (0,1)' ); // A full 2*PI rotation is the identity (up to floating-point drift). - const rotatedFull = rotate( vec2( 3, -2 ), float( Math.PI * 2 ) ); - assert.closeAbs( rotatedFull, vec2( 3, -2 ), 1e-3, 'rotate(v, 2*PI) returns to the original vector' ); + const rotatedFull = rotate( vec2( 3, - 2 ), float( Math.PI * 2 ) ); + assert.closeAbs( rotatedFull, vec2( 3, - 2 ), 1e-3, 'rotate(v, 2*PI) returns to the original vector' ); // Zero rotation is exactly the identity. assert.closeAbs( rotate( vec2( 5, 7 ), float( 0 ) ), vec2( 5, 7 ), 1e-5, 'rotate(v, 0) is the identity' ); @@ -58,7 +58,49 @@ export default QUnit.module( 'TSL', () => { // opposite way round from what the X/Z pattern alone would // suggest: x' = x*cos + z*sin, z' = -x*sin + z*cos. const rotated = rotate( vec3( 1, 5, 0 ), vec3( 0, Math.PI / 2, 0 ) ); - assert.closeAbs( rotated, vec3( 0, 5, -1 ), 1e-4, 'rotating (1,5,0) by PI/2 about Y gives (0,5,-1)' ); + assert.closeAbs( rotated, vec3( 0, 5, - 1 ), 1e-4, 'rotating (1,5,0) by PI/2 about Y gives (0,5,-1)' ); + + } ); + + gpuTest( 'rotate() default order is XYZ', ( { assert } ) => { + + // Omitting the order argument must be identical to passing 'XYZ'. + const position = vec3( 1, 0, 0 ); + const rotation = vec3( Math.PI / 2, Math.PI / 4, 0 ); + + assert.closeAbs( + rotate( position, rotation ), + rotate( position, rotation, 'XYZ' ), + 1e-5, + 'omit order == order "XYZ"' + ); + + } ); + + gpuTest( 'rotate() with order XYZ applies Rx*Ry*Rz', ( { assert } ) => { + + // (1,0,0) rotated by (π/2, π/4, 0) under XYZ → (√2/2, √2/2, 0). + const rotated = rotate( vec3( 1, 0, 0 ), vec3( Math.PI / 2, Math.PI / 4, 0 ), 'XYZ' ); + assert.closeAbs( rotated, vec3( Math.SQRT1_2, Math.SQRT1_2, 0 ), 1e-4, + 'XYZ: (1,0,0) by (π/2, π/4, 0) → (√2/2, √2/2, 0)' ); + + } ); + + gpuTest( 'rotate() with order YXZ applies Ry*Rx*Rz', ( { assert } ) => { + + // Same angles under YXZ → (√2/2, 0, -√2/2). Distinct from the XYZ result above. + const rotated = rotate( vec3( 1, 0, 0 ), vec3( Math.PI / 2, Math.PI / 4, 0 ), 'YXZ' ); + assert.closeAbs( rotated, vec3( Math.SQRT1_2, 0, - Math.SQRT1_2 ), 1e-4, + 'YXZ: (1,0,0) by (π/2, π/4, 0) → (√2/2, 0, -√2/2)' ); + + } ); + + gpuTest( 'rotate() zero rotation is identity for non-default orders', ( { assert } ) => { + + const v = vec3( 3, - 2, 7 ); + + assert.closeAbs( rotate( v, vec3( 0, 0, 0 ), 'YXZ' ), v, 1e-5, 'YXZ zero rotation is identity' ); + assert.closeAbs( rotate( v, vec3( 0, 0, 0 ), 'ZYX' ), v, 1e-5, 'ZYX zero rotation is identity' ); } );