three
Version:
JavaScript 3D library
550 lines (465 loc) • 16.4 kB
JavaScript
import { Matrix3, NodeMaterial, Vector3 } from 'three/webgpu';
import { clamp, Fn, vec4, uv, uniform, max } from 'three/tsl';
import StereoCompositePassNode from './StereoCompositePassNode.js';
import { frameCorners } from '../../utils/CameraUtils.js';
const _eyeL = /*@__PURE__*/ new Vector3();
const _eyeR = /*@__PURE__*/ new Vector3();
const _screenBottomLeft = /*@__PURE__*/ new Vector3();
const _screenBottomRight = /*@__PURE__*/ new Vector3();
const _screenTopLeft = /*@__PURE__*/ new Vector3();
const _right = /*@__PURE__*/ new Vector3();
const _up = /*@__PURE__*/ new Vector3();
const _forward = /*@__PURE__*/ new Vector3();
const _screenCenter = /*@__PURE__*/ new Vector3();
/**
* Anaglyph algorithm types.
* @readonly
* @enum {string}
*/
const AnaglyphAlgorithm = {
TRUE: 'true',
GREY: 'grey',
COLOUR: 'colour',
HALF_COLOUR: 'halfColour',
DUBOIS: 'dubois',
OPTIMISED: 'optimised',
COMPROMISE: 'compromise'
};
/**
* Anaglyph color modes.
* @readonly
* @enum {string}
*/
const AnaglyphColorMode = {
RED_CYAN: 'redCyan',
MAGENTA_CYAN: 'magentaCyan',
MAGENTA_GREEN: 'magentaGreen'
};
/**
* Standard luminance coefficients (ITU-R BT.601).
* @private
*/
const LUMINANCE = { R: 0.299, G: 0.587, B: 0.114 };
/**
* Creates an anaglyph matrix pair from left and right channel specifications.
* This provides a more intuitive way to define how source RGB channels map to output RGB channels.
*
* Each specification object has keys 'r', 'g', 'b' for output channels.
* Each output channel value is [rCoef, gCoef, bCoef] defining how much of each input channel contributes.
*
* @private
* @param {Object} leftSpec - Specification for left eye contribution
* @param {Object} rightSpec - Specification for right eye contribution
* @returns {{left: number[], right: number[]}} Column-major arrays for Matrix3
*/
function createMatrixPair( leftSpec, rightSpec ) {
// Convert row-major specification to column-major array for Matrix3
// Matrix3.fromArray expects [col0row0, col0row1, col0row2, col1row0, col1row1, col1row2, col2row0, col2row1, col2row2]
// Which represents:
// | col0row0 col1row0 col2row0 | | m[0] m[3] m[6] |
// | col0row1 col1row1 col2row1 | = | m[1] m[4] m[7] |
// | col0row2 col1row2 col2row2 | | m[2] m[5] m[8] |
function specToColumnMajor( spec ) {
const r = spec.r || [ 0, 0, 0 ]; // Output red channel coefficients [fromR, fromG, fromB]
const g = spec.g || [ 0, 0, 0 ]; // Output green channel coefficients
const b = spec.b || [ 0, 0, 0 ]; // Output blue channel coefficients
// Row-major matrix would be:
// | r[0] r[1] r[2] | (how input RGB maps to output R)
// | g[0] g[1] g[2] | (how input RGB maps to output G)
// | b[0] b[1] b[2] | (how input RGB maps to output B)
// Column-major for Matrix3:
return [
r[ 0 ], g[ 0 ], b[ 0 ], // Column 0: coefficients for input R
r[ 1 ], g[ 1 ], b[ 1 ], // Column 1: coefficients for input G
r[ 2 ], g[ 2 ], b[ 2 ] // Column 2: coefficients for input B
];
}
return {
left: specToColumnMajor( leftSpec ),
right: specToColumnMajor( rightSpec )
};
}
/**
* Shorthand for luminance coefficients.
* @private
*/
const LUM = [ LUMINANCE.R, LUMINANCE.G, LUMINANCE.B ];
/**
* Conversion matrices for different anaglyph algorithms.
* Based on research from "Introducing a New Anaglyph Method: Compromise Anaglyph" by Jure Ahtik
* and various other sources.
*
* Matrices are defined using createMatrixPair for clarity:
* - Each spec object defines how input RGB maps to output RGB
* - Keys 'r', 'g', 'b' represent output channels
* - Values are [rCoef, gCoef, bCoef] for input channel contribution
*
* @private
*/
const ANAGLYPH_MATRICES = {
// True Anaglyph - Red channel from left, luminance to cyan channel for right
// Paper: Left=[R,0,0], Right=[0,0,Lum]
[ AnaglyphAlgorithm.TRUE ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{ r: [ 1, 0, 0 ] }, // Left: R -> outR
{ g: LUM, b: LUM } // Right: Lum -> outG, Lum -> outB
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{ r: [ 1, 0, 0 ], b: [ 0, 0, 0.5 ] }, // Left: R -> outR, partial B -> outB
{ g: LUM, b: [ 0, 0, 0.5 ] } // Right: Lum -> outG, partial B
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{ r: [ 1, 0, 0 ], b: LUM }, // Left: R -> outR, Lum -> outB
{ g: LUM } // Right: Lum -> outG
)
},
// Grey Anaglyph - Luminance-based, no color, minimal ghosting
// Paper: Left=[Lum,0,0], Right=[0,0,Lum]
[ AnaglyphAlgorithm.GREY ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{ r: LUM }, // Left: Lum -> outR
{ g: LUM, b: LUM } // Right: Lum -> outG, Lum -> outB
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{ r: LUM, b: [ 0.15, 0.29, 0.06 ] }, // Left: Lum -> outR, half-Lum -> outB
{ g: LUM, b: [ 0.15, 0.29, 0.06 ] } // Right: Lum -> outG, half-Lum -> outB
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{ r: LUM, b: LUM }, // Left: Lum -> outR, Lum -> outB
{ g: LUM } // Right: Lum -> outG
)
},
// Colour Anaglyph - Full color, high retinal rivalry
// Paper: Left=[R,0,0], Right=[0,G,B]
[ AnaglyphAlgorithm.COLOUR ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{ r: [ 1, 0, 0 ] }, // Left: R -> outR
{ g: [ 0, 1, 0 ], b: [ 0, 0, 1 ] } // Right: G -> outG, B -> outB
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{ r: [ 1, 0, 0 ], b: [ 0, 0, 0.5 ] }, // Left: R -> outR, partial B -> outB
{ g: [ 0, 1, 0 ], b: [ 0, 0, 0.5 ] } // Right: G -> outG, partial B -> outB
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{ r: [ 1, 0, 0 ], b: [ 0, 0, 1 ] }, // Left: R -> outR, B -> outB
{ g: [ 0, 1, 0 ] } // Right: G -> outG
)
},
// Half-Colour Anaglyph - Luminance for left red, full color for right cyan
// Paper: Left=[Lum,0,0], Right=[0,G,B]
[ AnaglyphAlgorithm.HALF_COLOUR ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{ r: LUM }, // Left: Lum -> outR
{ g: [ 0, 1, 0 ], b: [ 0, 0, 1 ] } // Right: G -> outG, B -> outB
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{ r: LUM, b: [ 0.15, 0.29, 0.06 ] }, // Left: Lum -> outR, half-Lum -> outB
{ g: [ 0, 1, 0 ], b: [ 0.15, 0.29, 0.06 ] } // Right: G -> outG, half-Lum -> outB
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{ r: LUM, b: LUM }, // Left: Lum -> outR, Lum -> outB
{ g: [ 0, 1, 0 ] } // Right: G -> outG
)
},
// Dubois Anaglyph - Least-squares optimized for specific glasses
// From https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.7.6968&rep=rep1&type=pdf
[ AnaglyphAlgorithm.DUBOIS ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{
r: [ 0.4561, 0.500484, 0.176381 ],
g: [ - 0.0400822, - 0.0378246, - 0.0157589 ],
b: [ - 0.0152161, - 0.0205971, - 0.00546856 ]
},
{
r: [ - 0.0434706, - 0.0879388, - 0.00155529 ],
g: [ 0.378476, 0.73364, - 0.0184503 ],
b: [ - 0.0721527, - 0.112961, 1.2264 ]
}
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{
r: [ 0.4561, 0.500484, 0.176381 ],
g: [ - 0.0400822, - 0.0378246, - 0.0157589 ],
b: [ 0.088, 0.088, - 0.003 ]
},
{
r: [ - 0.0434706, - 0.0879388, - 0.00155529 ],
g: [ 0.378476, 0.73364, - 0.0184503 ],
b: [ 0.088, 0.088, 0.613 ]
}
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{
r: [ 0.4561, 0.500484, 0.176381 ],
b: [ - 0.0434706, - 0.0879388, - 0.00155529 ]
},
{
g: [ 0.378476 + 0.4561, 0.73364 + 0.500484, - 0.0184503 + 0.176381 ]
}
)
},
// Optimised Anaglyph - Improved color with reduced retinal rivalry
// Paper: Left=[0,0.7G+0.3B,0,0], Right=[0,G,B]
[ AnaglyphAlgorithm.OPTIMISED ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{ r: [ 0, 0.7, 0.3 ] }, // Left: 0.7G+0.3B -> outR
{ g: [ 0, 1, 0 ], b: [ 0, 0, 1 ] } // Right: G -> outG, B -> outB
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{ r: [ 0, 0.7, 0.3 ], b: [ 0, 0, 0.5 ] }, // Left: 0.7G+0.3B -> outR, partial B
{ g: [ 0, 1, 0 ], b: [ 0, 0, 0.5 ] } // Right: G -> outG, partial B
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{ r: [ 0, 0.7, 0.3 ], b: [ 0, 0, 1 ] }, // Left: 0.7G+0.3B -> outR, B -> outB
{ g: [ 0, 1, 0 ] } // Right: G -> outG
)
},
// Compromise Anaglyph - Best balance of color and stereo effect
// From Ahtik, J., "Techniques of Rendering Anaglyphs for Use in Art"
// Paper matrix [8]: Left=[0.439R+0.447G+0.148B, 0, 0], Right=[0, 0.095R+0.934G+0.005B, 0.018R+0.028G+1.057B]
[ AnaglyphAlgorithm.COMPROMISE ]: {
[ AnaglyphColorMode.RED_CYAN ]: createMatrixPair(
{ r: [ 0.439, 0.447, 0.148 ] }, // Left: weighted RGB -> outR
{
g: [ 0.095, 0.934, 0.005 ], // Right: weighted RGB -> outG
b: [ 0.018, 0.028, 1.057 ] // Right: weighted RGB -> outB
}
),
[ AnaglyphColorMode.MAGENTA_CYAN ]: createMatrixPair(
{
r: [ 0.439, 0.447, 0.148 ],
b: [ 0.009, 0.014, 0.074 ] // Partial blue from left
},
{
g: [ 0.095, 0.934, 0.005 ],
b: [ 0.009, 0.014, 0.528 ] // Partial blue from right
}
),
[ AnaglyphColorMode.MAGENTA_GREEN ]: createMatrixPair(
{
r: [ 0.439, 0.447, 0.148 ],
b: [ 0.018, 0.028, 1.057 ]
},
{
g: [ 0.095 + 0.439, 0.934 + 0.447, 0.005 + 0.148 ]
}
)
}
};
/**
* A render pass node that creates an anaglyph effect using physically-correct
* off-axis stereo projection.
*
* This implementation uses CameraUtils.frameCorners() to align stereo
* camera frustums to a virtual screen plane, providing accurate depth
* perception with zero parallax at the plane distance.
*
* @augments StereoCompositePassNode
* @three_import import { anaglyphPass, AnaglyphAlgorithm, AnaglyphColorMode } from 'three/addons/tsl/display/AnaglyphPassNode.js';
*/
class AnaglyphPassNode extends StereoCompositePassNode {
static get type() {
return 'AnaglyphPassNode';
}
/**
* Constructs a new anaglyph pass node.
*
* @param {Scene} scene - The scene to render.
* @param {Camera} camera - The camera to render the scene with.
*/
constructor( scene, camera ) {
super( scene, camera );
/**
* This flag can be used for type testing.
*
* @type {boolean}
* @readonly
* @default true
*/
this.isAnaglyphPassNode = true;
/**
* The interpupillary distance (eye separation) in world units.
* Typical human IPD is 0.064 meters (64mm).
*
* @type {number}
* @default 0.064
*/
this.eyeSep = 0.064;
/**
* The distance in world units from the viewer to the virtual
* screen plane where zero parallax (screen depth) occurs.
* Objects at this distance appear at the screen surface.
* Objects closer appear in front of the screen (negative parallax).
* Objects further appear behind the screen (positive parallax).
*
* The screen dimensions are derived from the camera's FOV and aspect ratio
* at this distance, ensuring the stereo view matches the camera's field of view.
*
* @type {number}
* @default 0.5
*/
this.planeDistance = 0.5;
/**
* The current anaglyph algorithm.
*
* @private
* @type {string}
* @default 'dubois'
*/
this._algorithm = AnaglyphAlgorithm.DUBOIS;
/**
* The current color mode.
*
* @private
* @type {string}
* @default 'redCyan'
*/
this._colorMode = AnaglyphColorMode.RED_CYAN;
/**
* Color matrix node for the left eye.
*
* @private
* @type {UniformNode<mat3>}
*/
this._colorMatrixLeft = uniform( new Matrix3() );
/**
* Color matrix node for the right eye.
*
* @private
* @type {UniformNode<mat3>}
*/
this._colorMatrixRight = uniform( new Matrix3() );
// Initialize with default matrices
this._updateMatrices();
}
/**
* Gets the current anaglyph algorithm.
*
* @type {string}
*/
get algorithm() {
return this._algorithm;
}
/**
* Sets the anaglyph algorithm.
*
* @type {string}
*/
set algorithm( value ) {
if ( this._algorithm !== value ) {
this._algorithm = value;
this._updateMatrices();
}
}
/**
* Gets the current color mode.
*
* @type {string}
*/
get colorMode() {
return this._colorMode;
}
/**
* Sets the color mode.
*
* @type {string}
*/
set colorMode( value ) {
if ( this._colorMode !== value ) {
this._colorMode = value;
this._updateMatrices();
}
}
/**
* Updates the color matrices based on current algorithm and color mode.
*
* @private
*/
_updateMatrices() {
const matrices = ANAGLYPH_MATRICES[ this._algorithm ][ this._colorMode ];
this._colorMatrixLeft.value.fromArray( matrices.left );
this._colorMatrixRight.value.fromArray( matrices.right );
}
/**
* Updates the internal stereo camera using frameCorners for
* physically-correct off-axis projection.
*
* @param {number} coordinateSystem - The current coordinate system.
*/
updateStereoCamera( coordinateSystem ) {
const { stereo, camera } = this;
stereo.cameraL.coordinateSystem = coordinateSystem;
stereo.cameraR.coordinateSystem = coordinateSystem;
// Get the camera's local coordinate axes from its world matrix
camera.matrixWorld.extractBasis( _right, _up, _forward );
_right.normalize();
_up.normalize();
_forward.normalize();
// Calculate eye positions
const halfSep = this.eyeSep / 2;
_eyeL.copy( camera.position ).addScaledVector( _right, - halfSep );
_eyeR.copy( camera.position ).addScaledVector( _right, halfSep );
// Calculate screen center (at planeDistance in front of the camera center)
_screenCenter.copy( camera.position ).addScaledVector( _forward, - this.planeDistance );
// Calculate screen dimensions from camera FOV and aspect ratio
const DEG2RAD = Math.PI / 180;
const halfHeight = this.planeDistance * Math.tan( DEG2RAD * camera.fov / 2 );
const halfWidth = halfHeight * camera.aspect;
// Calculate screen corners
_screenBottomLeft.copy( _screenCenter )
.addScaledVector( _right, - halfWidth )
.addScaledVector( _up, - halfHeight );
_screenBottomRight.copy( _screenCenter )
.addScaledVector( _right, halfWidth )
.addScaledVector( _up, - halfHeight );
_screenTopLeft.copy( _screenCenter )
.addScaledVector( _right, - halfWidth )
.addScaledVector( _up, halfHeight );
// Set up left eye camera
stereo.cameraL.position.copy( _eyeL );
stereo.cameraL.near = camera.near;
stereo.cameraL.far = camera.far;
frameCorners( stereo.cameraL, _screenBottomLeft, _screenBottomRight, _screenTopLeft, true );
stereo.cameraL.matrixWorld.compose( stereo.cameraL.position, stereo.cameraL.quaternion, stereo.cameraL.scale );
stereo.cameraL.matrixWorldInverse.copy( stereo.cameraL.matrixWorld ).invert();
// Set up right eye camera
stereo.cameraR.position.copy( _eyeR );
stereo.cameraR.near = camera.near;
stereo.cameraR.far = camera.far;
frameCorners( stereo.cameraR, _screenBottomLeft, _screenBottomRight, _screenTopLeft, true );
stereo.cameraR.matrixWorld.compose( stereo.cameraR.position, stereo.cameraR.quaternion, stereo.cameraR.scale );
stereo.cameraR.matrixWorldInverse.copy( stereo.cameraR.matrixWorld ).invert();
}
/**
* This method is used to setup the effect's TSL code.
*
* @param {NodeBuilder} builder - The current node builder.
* @return {PassTextureNode}
*/
setup( builder ) {
const uvNode = uv();
const anaglyph = Fn( () => {
const colorL = this._mapLeft.sample( uvNode );
const colorR = this._mapRight.sample( uvNode );
const color = clamp( this._colorMatrixLeft.mul( colorL.rgb ).add( this._colorMatrixRight.mul( colorR.rgb ) ) );
return vec4( color.rgb, max( colorL.a, colorR.a ) );
} );
const material = this._material || ( this._material = new NodeMaterial() );
material.fragmentNode = anaglyph().context( builder.getSharedContext() );
material.name = 'Anaglyph';
material.needsUpdate = true;
return super.setup( builder );
}
}
export default AnaglyphPassNode;
export { AnaglyphAlgorithm, AnaglyphColorMode };
/**
* TSL function for creating an anaglyph pass node.
*
* @tsl
* @function
* @param {Scene} scene - The scene to render.
* @param {Camera} camera - The camera to render the scene with.
* @returns {AnaglyphPassNode}
*/
export const anaglyphPass = ( scene, camera ) => new AnaglyphPassNode( scene, camera );