Kaynağa Gözat

TSL: Add `retroPass` and example (#32930)

sunag 3 hafta önce
ebeveyn
işleme
7b2c6839b7

+ 1 - 0
examples/files.json

@@ -424,6 +424,7 @@
 		"webgpu_postprocessing_outline",
 		"webgpu_postprocessing_pixel",
 		"webgpu_postprocessing_radial_blur",
+		"webgpu_postprocessing_retro",
 		"webgpu_postprocessing_smaa",
 		"webgpu_postprocessing_sobel",
 		"webgpu_postprocessing_ssaa",

+ 150 - 0
examples/jsm/tsl/display/CRT.js

@@ -0,0 +1,150 @@
+import { Fn, float, vec2, vec3, sin, screenUV, mix, clamp, dot, convertToTexture, time, uv, select } from 'three/tsl';
+import { circle } from './Shape.js';
+
+/**
+ * Creates barrel-distorted UV coordinates.
+ * The center of the screen appears to bulge outward (convex distortion).
+ *
+ * @tsl
+ * @function
+ * @param {Node<float>} [curvature=0.1] - The amount of curvature (0 = flat, 0.5 = very curved).
+ * @param {Node<vec2>} [coord=uv()] - The input UV coordinates.
+ * @return {Node<vec2>} The distorted UV coordinates.
+ */
+export const barrelUV = Fn( ( [ curvature = float( 0.1 ), coord = uv() ] ) => {
+
+	// Center UV coordinates (-1 to 1)
+	const centered = coord.sub( 0.5 ).mul( 2.0 );
+
+	// Calculate squared distance from center
+	const r2 = dot( centered, centered );
+
+	// Barrel distortion: push center outward (bulge effect)
+	const distortion = float( 1.0 ).sub( r2.mul( curvature ) );
+
+	// Calculate scale to compensate for edge expansion
+	// At corners r² = 2, so we scale by the inverse of corner distortion
+	const cornerDistortion = float( 1.0 ).sub( curvature.mul( 2.0 ) );
+
+	// Apply distortion and compensate scale to keep edges aligned
+	const distorted = centered.div( distortion ).mul( cornerDistortion ).mul( 0.5 ).add( 0.5 );
+
+	return distorted;
+
+} );
+
+/**
+ * Checks if UV coordinates are inside the valid 0-1 range.
+ * Useful for masking areas inside the distorted screen.
+ *
+ * @tsl
+ * @function
+ * @param {Node<vec2>} coord - The UV coordinates to check.
+ * @return {Node<float>} 1.0 if inside bounds, 0.0 if outside.
+ */
+export const barrelMask = Fn( ( [ coord ] ) => {
+
+	const outOfBounds = coord.x.lessThan( 0.0 )
+		.or( coord.x.greaterThan( 1.0 ) )
+		.or( coord.y.lessThan( 0.0 ) )
+		.or( coord.y.greaterThan( 1.0 ) );
+
+	return select( outOfBounds, float( 0.0 ), float( 1.0 ) );
+
+} );
+
+/**
+ * Applies color bleeding effect to simulate horizontal color smearing.
+ * Simulates the analog signal bleeding in CRT displays where colors
+ * "leak" into adjacent pixels horizontally.
+ *
+ * @tsl
+ * @function
+ * @param {Node} color - The input texture node.
+ * @param {Node<float>} [amount=0.002] - The amount of color bleeding (0-0.01).
+ * @return {Node<vec3>} The color with bleeding effect applied.
+ */
+export const colorBleeding = Fn( ( [ color, amount = float( 0.002 ) ] ) => {
+
+	const inputTexture = convertToTexture( color );
+
+	// Get the original color
+	const original = inputTexture.sample( screenUV ).rgb;
+
+	// Sample colors from the left (simulating signal trailing)
+	const left1 = inputTexture.sample( screenUV.sub( vec2( amount, 0.0 ) ) ).rgb;
+	const left2 = inputTexture.sample( screenUV.sub( vec2( amount.mul( 2.0 ), 0.0 ) ) ).rgb;
+	const left3 = inputTexture.sample( screenUV.sub( vec2( amount.mul( 3.0 ), 0.0 ) ) ).rgb;
+
+	// Red bleeds more (travels further in analog signal)
+	const bleedR = original.r
+		.add( left1.r.mul( 0.4 ) )
+		.add( left2.r.mul( 0.2 ) )
+		.add( left3.r.mul( 0.1 ) );
+
+	// Green bleeds medium
+	const bleedG = original.g
+		.add( left1.g.mul( 0.25 ) )
+		.add( left2.g.mul( 0.1 ) );
+
+	// Blue bleeds least
+	const bleedB = original.b
+		.add( left1.b.mul( 0.15 ) );
+
+	// Normalize and clamp
+	const r = clamp( bleedR.div( 1.7 ), 0.0, 1.0 );
+	const g = clamp( bleedG.div( 1.35 ), 0.0, 1.0 );
+	const b = clamp( bleedB.div( 1.15 ), 0.0, 1.0 );
+
+	return vec3( r, g, b );
+
+} );
+
+/**
+ * Applies scanline effect to simulate CRT monitor horizontal lines with animation.
+ *
+ * @tsl
+ * @function
+ * @param {Node<vec3>} color - The input color.
+ * @param {Node<float>} [intensity=0.3] - The intensity of the scanlines (0-1).
+ * @param {Node<float>} [count=240] - The number of scanlines (typically matches vertical resolution).
+ * @param {Node<float>} [speed=0.0] - The scroll speed of scanlines (0 = static, 1 = normal CRT roll).
+ * @param {Node<vec2>} [coord=uv()] - The UV coordinates to use for scanlines.
+ * @return {Node<vec3>} The color with scanlines applied.
+ */
+export const scanlines = Fn( ( [ color, intensity = float( 0.3 ), count = float( 240.0 ), speed = float( 0.0 ), coord = uv() ] ) => {
+
+	// Animate scanlines scrolling down (like CRT vertical sync roll)
+	const animatedY = coord.y.sub( time.mul( speed ) );
+
+	// Create scanline pattern
+	const scanline = sin( animatedY.mul( count ) );
+	const scanlineIntensity = scanline.mul( 0.5 ).add( 0.5 ).mul( intensity );
+
+	// Darken alternate lines
+	return color.mul( float( 1.0 ).sub( scanlineIntensity ) );
+
+} );
+
+/**
+ * Applies vignette effect to darken the edges of the screen.
+ *
+ * @tsl
+ * @function
+ * @param {Node<vec3>} color - The input color.
+ * @param {Node<float>} [intensity=0.4] - The intensity of the vignette (0-1).
+ * @param {Node<float>} [smoothness=0.5] - The smoothness of the vignette falloff.
+ * @param {Node<vec2>} [coord=uv()] - The UV coordinates to use for vignette calculation.
+ * @return {Node<vec3>} The color with vignette applied.
+ */
+export const vignette = Fn( ( [ color, intensity = float( 0.4 ), smoothness = float( 0.5 ), coord = uv() ] ) => {
+
+	// Use circle for radial gradient (1.42 ≈ √2 covers full diagonal)
+	const mask = circle( float( 1.42 ), smoothness, coord );
+
+	// Apply vignette: center = 1, edges = (1 - intensity)
+	const vignetteAmount = mix( float( 1.0 ).sub( intensity ), float( 1.0 ), mask );
+
+	return color.mul( vignetteAmount );
+
+} );

+ 166 - 0
examples/jsm/tsl/display/RetroPassNode.js

@@ -0,0 +1,166 @@
+import { MeshBasicNodeMaterial, PassNode, UnsignedByteType, NearestFilter } from 'three/webgpu';
+import { float, vec2, vec4, Fn, uv, varying, cameraProjectionMatrix, cameraViewMatrix, positionWorld, screenSize, materialColor, replaceDefaultUV } from 'three/tsl';
+
+const _affineUv = varying( vec2() );
+const _w = varying( float() );
+
+const _clipSpaceRetro = Fn( () => {
+
+	const defaultPosition = cameraProjectionMatrix
+		.mul( cameraViewMatrix )
+		.mul( positionWorld );
+
+	const roundedPosition = defaultPosition.xy
+		.div( defaultPosition.w.mul( 2 ) )
+		.mul( screenSize.xy )
+		.round()
+		.div( screenSize.xy )
+		.mul( defaultPosition.w.mul( 2 ) );
+
+	_affineUv.assign( uv().mul( defaultPosition.w ) );
+	_w.assign( defaultPosition.w );
+
+	return vec4( roundedPosition.xy, defaultPosition.zw );
+
+} )();
+
+/**
+ * A post-processing pass that applies a retro PS1-style effect to the scene.
+ *
+ * This node renders the scene with classic PlayStation 1 visual characteristics:
+ * - **Vertex snapping**: Vertices are snapped to screen pixels, creating the iconic "wobbly" geometry
+ * - **Affine texture mapping**: Textures are sampled without perspective correction, resulting in distortion effects
+ * - **Low resolution**: Default 0.25 scale (typical 320x240 equivalent)
+ * - **Nearest-neighbor filtering**: Sharp pixelated textures without smoothing
+ *
+ * @augments PassNode
+ */
+class RetroPassNode extends PassNode {
+
+	/**
+	 * Creates a new RetroPassNode instance.
+	 *
+	 * @param {Scene} scene - The scene to render.
+	 * @param {Camera} camera - The camera to render from.
+	 * @param {Object} [options={}] - Additional options for the retro pass.
+	 * @param {Node} [options.affineDistortion=null] - An optional node to apply affine distortion to UVs.
+	 */
+	constructor( scene, camera, options = {} ) {
+
+		super( PassNode.COLOR, scene, camera );
+
+		const {
+			affineDistortion = null
+		} = options;
+
+		this.setResolutionScale( .25 );
+
+		this.renderTarget.texture.type = UnsignedByteType;
+		this.renderTarget.texture.magFilter = NearestFilter;
+		this.renderTarget.texture.minFilter = NearestFilter;
+
+		this.affineDistortionNode = affineDistortion;
+
+		this._materialCache = new Map();
+
+	}
+
+	/**
+	 * Updates the retro pass before rendering.
+	 *
+	 * @override
+	 * @param {Frame} frame - The current frame information.
+	 * @returns {void}
+	 */
+	updateBefore( frame ) {
+
+		const renderer = frame.renderer;
+
+		const currentRenderObjectFunction = renderer.getRenderObjectFunction();
+
+		renderer.setRenderObjectFunction( ( object, scene, camera, geometry, material, ...params ) => {
+
+			let retroMaterial = this._materialCache.get( material );
+
+			if ( retroMaterial === undefined ) {
+
+				retroMaterial = new MeshBasicNodeMaterial();
+
+				retroMaterial.colorNode = material.colorNode || null;
+				retroMaterial.opacityNode = material.opacityNode || null;
+				retroMaterial.positionNode = material.positionNode || null;
+				retroMaterial.vertexNode = material.vertexNode || _clipSpaceRetro;
+
+				if ( this.affineDistortionNode ) {
+
+					retroMaterial.colorNode = replaceDefaultUV( () => {
+
+						return this.affineDistortionNode.mix( uv(), _affineUv.div( _w ) );
+
+					}, retroMaterial.colorNode || materialColor );
+
+				}
+
+				this._materialCache.set( material, retroMaterial );
+
+			}
+
+			retroMaterial.map = material.map;
+			retroMaterial.color = material.color;
+			retroMaterial.opacity = material.opacity;
+			retroMaterial.transparent = material.transparent;
+			retroMaterial.side = material.side;
+
+			renderer.renderObject( object, scene, camera, geometry, retroMaterial, ...params );
+
+		} );
+
+		super.updateBefore( frame );
+
+		renderer.setRenderObjectFunction( currentRenderObjectFunction );
+
+	}
+
+	/**
+	 * Disposes the retro pass and its internal resources.
+	 *
+	 * @override
+	 * @returns {void}
+	 */
+	dispose() {
+
+		super.dispose();
+
+		this._materialCache.forEach( material => material.dispose() );
+		this._materialCache.clear();
+
+	}
+
+}
+
+export default RetroPassNode;
+
+/**
+ * Creates a new RetroPassNode instance for PS1-style rendering.
+ *
+ * The retro pass applies vertex snapping, affine texture mapping, and low-resolution
+ * rendering to achieve an authentic PlayStation 1 aesthetic. Combine with other
+ * post-processing effects like dithering, posterization, and scanlines for full retro look.
+ *
+ * ```js
+ * // Combined with other effects
+ * let pipeline = retroPass( scene, camera );
+ * pipeline = bayerDither( pipeline, 32 );
+ * pipeline = posterize( pipeline, 32 );
+ * renderPipeline.outputNode = pipeline;
+ * ```
+ *
+ * @tsl
+ * @function
+ * @param {Scene} scene - The scene to render.
+ * @param {Camera} camera - The camera to render from.
+ * @param {Object} [options={}] - Additional options for the retro pass.
+ * @param {Node} [options.affineDistortion=null] - An optional node to apply affine distortion to UVs.
+ * @return {RetroPassNode} A new RetroPassNode instance.
+ */
+export const retroPass = ( scene, camera, options = {} ) => new RetroPassNode( scene, camera, options );

+ 29 - 0
examples/jsm/tsl/display/Shape.js

@@ -0,0 +1,29 @@
+import { Fn, float, length, smoothstep, uv } from 'three/tsl';
+
+/**
+ * Returns a radial gradient from center (white) to edges (black).
+ * Useful for masking effects based on distance from center.
+ *
+ * @tsl
+ * @function
+ * @param {Node<float>} [scale=1.0] - Controls the size of the gradient (0 = all black, 1 = full circle).
+ * @param {Node<float>} [softness=0.5] - Controls the edge softness (0 = hard edge, 1 = soft gradient).
+ * @param {Node<vec2>} [coord=uv()] - The input UV coordinates.
+ * @return {Node<float>} 1.0 at center, 0.0 at edges.
+ */
+export const circle = Fn( ( [ scale = float( 1.0 ), softness = float( 0.5 ), coord = uv() ] ) => {
+
+	// Center UV coordinates (-0.5 to 0.5)
+	const centered = coord.sub( 0.5 );
+
+	// Calculate distance from center (0 at center, ~0.707 at corners)
+	const dist = length( centered ).mul( 2.0 );
+
+	// Calculate inner and outer edges based on scale and softness
+	const outer = scale;
+	const inner = scale.sub( softness.mul( scale ) );
+
+	// Smoothstep for soft/hard transition
+	return smoothstep( outer, inner, dist );
+
+} );

+ 40 - 1
examples/jsm/tsl/math/Bayer.js

@@ -1,5 +1,5 @@
 import { TextureLoader } from 'three';
-import { Fn, int, ivec2, textureLoad } from 'three/tsl';
+import { Fn, int, ivec2, textureLoad, screenUV, screenSize, mod, floor, float, vec3 } from 'three/tsl';
 
 /**
  * @module Bayer
@@ -32,3 +32,42 @@ export const bayer16 = Fn( ( [ uv ] ) => {
 	return textureLoad( bayer16Texture, ivec2( uv ).mod( int( 16 ) ) );
 
 } );
+
+/**
+ * This TSL function applies Bayer dithering to a color input. It uses a 4x4 Bayer matrix
+ * pattern to add structured noise before color quantization, which helps reduce visible
+ * color banding when limiting color depth.
+ *
+ * @tsl
+ * @function
+ * @param {Node<vec3>} color - The input color to apply dithering to.
+ * @param {Node<float>} [steps=32] - The number of color steps per channel.
+ * @return {Node<vec3>} The dithered color ready for quantization.
+ *
+ * @example
+ * // Apply dithering with posterize
+ * const ditheredColor = bayerDither( inputColor, 32 );
+ * const finalColor = posterize( ditheredColor, 32 );
+ */
+export const bayerDither = Fn( ( [ color, steps = float( 32.0 ) ] ) => {
+
+	const screenPos = screenUV.mul( screenSize );
+	const x = mod( floor( screenPos.x ), float( 4.0 ) );
+	const y = mod( floor( screenPos.y ), float( 4.0 ) );
+
+	// Simplified Bayer matrix approximation
+	const bayer = mod(
+		floor( x.add( 1.0 ) ).mul( floor( y.add( 1.0 ) ) ).mul( 17.0 ),
+		16.0
+	).div( 16.0 ).sub( 0.5 );
+
+	// Apply dither offset before quantization
+	const ditherOffset = bayer.div( steps );
+
+	return vec3(
+		color.r.add( ditherOffset ),
+		color.g.add( ditherOffset ),
+		color.b.add( ditherOffset )
+	);
+
+} );

BIN
examples/screenshots/webgpu_postprocessing_retro.jpg


+ 320 - 0
examples/webgpu_postprocessing_retro.html

@@ -0,0 +1,320 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<title>three.js webgpu - retro</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="example.css">
+	</head>
+	<body>
+
+		<div id="info">
+			<a href="https://threejs.org/" target="_blank" rel="noopener" class="logo-link"></a>
+
+			<div class="title-wrapper">
+				<a href="https://threejs.org/" target="_blank" rel="noopener">three.js</a><span>Retro</span>
+			</div>
+
+			<small>
+				Retro post-processing effects, with scene based on <a href="https://threejs-journey.com/lessons/coffee-smoke-shader" target="_blank" rel="noopener">Three.js Journey</a> lesson.<br />
+				Perlin noise texture from <a href="http://kitfox.com/projects/perlinNoiseMaker/" target="_blank" rel="noopener">Perlin Noise Maker</a>.
+			</small>
+		</div>
+
+		<script type="importmap">
+			{
+				"imports": {
+					"three": "../build/three.webgpu.js",
+					"three/webgpu": "../build/three.webgpu.js",
+					"three/tsl": "../build/three.tsl.js",
+					"three/addons/": "./jsm/"
+				}
+			}
+		</script>
+
+		<script type="module">
+
+			import * as THREE from 'three/webgpu';
+			import { pass, mix, mul, oneMinus, positionLocal, smoothstep, texture, time, rotateUV, Fn, uv, vec2, vec3, vec4, uniform, posterize, floor, float, sin, fract, dot, step, color, normalWorld, length, atan, replaceDefaultUV } from 'three/tsl';
+			import { retroPass } from 'three/addons/tsl/display/RetroPassNode.js';
+			import { bayerDither } from 'three/addons/tsl/math/Bayer.js';
+			import { scanlines, vignette, colorBleeding, barrelUV } from 'three/addons/tsl/display/CRT.js';
+			import { circle } from 'three/addons/tsl/display/Shape.js';
+
+			import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+			import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
+
+			import { Inspector } from 'three/addons/inspector/Inspector.js';
+
+			let camera, scene, renderer, renderPipeline, controls;
+
+			init();
+
+			async function init() {
+
+				camera = new THREE.PerspectiveCamera( 25, window.innerWidth / window.innerHeight, 0.1, 100 );
+				camera.position.set( 8, 5, 20 );
+
+				scene = new THREE.Scene();
+
+				// PS1-style background: gradient sky with simple stars
+
+				const ps1Background = Fn( () => {
+
+					// Flip Y coordinate for correct orientation
+					const flippedY = normalWorld.y.negate();
+					const skyUV = flippedY.mul( 0.5 ).add( 0.5 );
+
+					// Simple gradient sky (dark blue at top to purple/orange at horizon)
+					const topColor = color( 0x000033 ); // dark blue night sky
+					const midColor = color( 0x330066 ); // purple
+					const horizonColor = color( 0x663322 ); // warm orange/brown horizon
+
+					// Two-step gradient (inverted - top is dark, horizon is warm)
+					const skyGradient = mix(
+						horizonColor,
+						mix( midColor, topColor, skyUV.smoothstep( 0.4, 0.9 ) ),
+						skyUV.smoothstep( 0.0, 0.4 )
+					);
+
+					// PS1-style "stars" using spherical coordinates
+					const longitude = atan( normalWorld.x, normalWorld.z );
+					const latitude = flippedY.asin(); // Use flipped Y for latitude too
+
+					// More stars with smaller scale
+					const starScale = float( 50.0 );
+					const starUV = vec2( longitude.mul( starScale ), latitude.mul( starScale ) );
+					const starCell = floor( starUV );
+
+					// Hash for randomness
+					const cellHash = fract( sin( dot( starCell, vec2( 12.9898, 78.233 ) ) ).mul( 43758.5453 ) );
+
+					// Position within cell (0-1)
+					const cellUV = fract( starUV );
+					const toCenter = cellUV.sub( 0.5 );
+
+					// Gemini-style star: bright center with soft glow + cross flare
+					const distToCenter = length( toCenter );
+
+					// Core (small bright center)
+					const core = smoothstep( float( 0.08 ), float( 0.0 ), distToCenter );
+
+					// Soft glow around
+					const glow = smoothstep( float( 0.25 ), float( 0.0 ), distToCenter ).mul( 0.4 );
+
+					// Cross/diamond flare effect
+					const crossX = smoothstep( float( 0.15 ), float( 0.0 ), toCenter.x.abs() ).mul( smoothstep( float( 0.4 ), float( 0.0 ), toCenter.y.abs() ) );
+					const crossY = smoothstep( float( 0.15 ), float( 0.0 ), toCenter.y.abs() ).mul( smoothstep( float( 0.4 ), float( 0.0 ), toCenter.x.abs() ) );
+					const cross = crossX.add( crossY ).mul( 0.3 );
+
+					// Combine star shape
+					const starShape = core.add( glow ).add( cross );
+
+					// More stars (lower threshold = more stars)
+					const isStar = step( 0.85, cellHash );
+
+					// Show stars from horizon up
+					const aboveHorizon = smoothstep( float( - 0.2 ), float( 0.1 ), flippedY );
+
+					// Star brightness varies + twinkle color
+					const starIntensity = isStar.mul( aboveHorizon ).mul( starShape ).mul( cellHash.mul( 0.6 ).add( 0.4 ) );
+
+					// Slight color variation (white to light blue)
+					const starColor = mix( vec3( 1.0, 1.0, 0.95 ), vec3( 0.8, 0.9, 1.0 ), cellHash );
+
+					// Combine sky and stars
+					const finalColor = mix( skyGradient, starColor, starIntensity.clamp( 0.0, 1.0 ) );
+
+					return finalColor;
+
+				} )();
+
+				scene.backgroundNode = ps1Background;
+
+				// Loaders
+
+				const gltfLoader = new GLTFLoader();
+				const textureLoader = new THREE.TextureLoader();
+
+				// baked model
+
+				gltfLoader.load(
+					'./models/gltf/coffeeMug.glb',
+					( gltf ) => {
+
+        				gltf.scene.getObjectByName( 'baked' ).material.map.anisotropy = 8;
+						scene.add( gltf.scene );
+
+					}
+				);
+
+				// geometry
+
+				const smokeGeometry = new THREE.PlaneGeometry( 1, 1, 16, 64 );
+				smokeGeometry.translate( 0, 0.5, 0 );
+				smokeGeometry.scale( 1.5, 6, 1.5 );
+
+				// texture
+
+				const noiseTexture = textureLoader.load( './textures/noises/perlin/128x128.png' );
+				noiseTexture.wrapS = THREE.RepeatWrapping;
+				noiseTexture.wrapT = THREE.RepeatWrapping;
+
+				// material
+
+				const smokeMaterial = new THREE.MeshBasicNodeMaterial( { transparent: true, side: THREE.DoubleSide, depthWrite: false } );
+
+				// position
+
+				smokeMaterial.positionNode = Fn( () => {
+
+					// twist
+
+					const twistNoiseUv = vec2( 0.5, uv().y.mul( 0.2 ).sub( time.mul( 0.005 ) ).mod( 1 ) );
+					const twist = texture( noiseTexture, twistNoiseUv ).r.mul( 10 );
+					positionLocal.xz.assign( rotateUV( positionLocal.xz, twist, vec2( 0 ) ) );
+
+					// wind
+
+					const windOffset = vec2(
+						texture( noiseTexture, vec2( 0.25, time.mul( 0.01 ) ).mod( 1 ) ).r.sub( 0.5 ),
+						texture( noiseTexture, vec2( 0.75, time.mul( 0.01 ) ).mod( 1 ) ).r.sub( 0.5 ),
+					).mul( uv().y.pow( 2 ).mul( 10 ) );
+					positionLocal.addAssign( windOffset );
+
+					return positionLocal;
+
+				} )();
+
+				// color
+
+				smokeMaterial.colorNode = Fn( () => {
+
+					// alpha
+
+					const alphaNoiseUv = uv().mul( vec2( 0.5, 0.3 ) ).add( vec2( 0, time.mul( 0.03 ).negate() ) );
+					const alpha = mul(
+
+						// pattern
+						texture( noiseTexture, alphaNoiseUv ).r.smoothstep( 0.4, 1 ),
+
+						// edges fade
+						smoothstep( 0, 0.1, uv().x ),
+						smoothstep( 0, 0.1, oneMinus( uv().x ) ),
+						smoothstep( 0, 0.1, uv().y ),
+						smoothstep( 0, 0.1, oneMinus( uv().y ) )
+
+					);
+
+					// color
+
+					const finalColor = mix( vec3( 0.6, 0.3, 0.2 ), vec3( 1, 1, 1 ), alpha.pow( 3 ) );
+
+					return vec4( finalColor, alpha );
+
+				} )();
+
+				// mesh
+
+				const smoke = new THREE.Mesh( smokeGeometry, smokeMaterial );
+				smoke.position.y = 1.83;
+				scene.add( smoke );
+
+				// renderer
+
+				renderer = new THREE.WebGPURenderer( { antialias: true } );
+				renderer.setPixelRatio( window.devicePixelRatio );
+				renderer.setSize( window.innerWidth, window.innerHeight );
+				renderer.setAnimationLoop( animate );
+				renderer.inspector = new Inspector();
+				document.body.appendChild( renderer.domElement );
+
+				// uniforms
+
+				// PS1-style: 15-bit color (32 levels per channel)
+				const colorDepthSteps = uniform( 32 );
+
+				// CRT effect parameters (subtle for PS1 look)
+				const scanlineIntensity = uniform( 0.08 ); // subtle scanlines
+				const scanlineCount = uniform( 60 ); // match vertical resolution
+				const scanlineSpeed = uniform( 0.05 ); // slow scroll
+				const vignetteIntensity = uniform( 0.3 ); // subtle vignette
+				const bleeding = uniform( 0.001 ); // minimal bleeding
+				const curvature = uniform( 0.02 ); // subtle curve
+				const affineDistortion = uniform( 0 ); // no affine distortion
+
+				// render pipeline
+
+				renderPipeline = new THREE.RenderPipeline( renderer );
+
+				// retro pipeline
+
+				const distortedUV = barrelUV( curvature );
+				const distortedDelta = circle( curvature.add( .1 ).mul( 10 ), 1 ).mul( curvature ).mul( .05 );
+
+				let retroPipeline = retroPass( scene, camera, { affineDistortion } );
+				retroPipeline = replaceDefaultUV( distortedUV, retroPipeline );
+				retroPipeline = colorBleeding( retroPipeline, bleeding.add( distortedDelta ) );
+				retroPipeline = bayerDither( retroPipeline, colorDepthSteps );
+				retroPipeline = posterize( retroPipeline, colorDepthSteps );
+				retroPipeline = vignette( retroPipeline, vignetteIntensity, 0.6 );
+				retroPipeline = scanlines( retroPipeline, scanlineIntensity, scanlineCount, scanlineSpeed );
+
+				renderPipeline.outputNode = retroPipeline;
+
+				// default pass (no post-processing)
+
+				const defaultPass = pass( scene, camera );
+
+				// controls
+
+				controls = new OrbitControls( camera, renderer.domElement );
+				controls.enableDamping = true;
+				controls.minDistance = 0.1;
+				controls.maxDistance = 50;
+				controls.target.y = 1;
+
+				// gui
+
+				const gui = renderer.inspector.createParameters( 'Settings' );
+
+				gui.add( { enabled: true }, 'enabled' ).onChange( v => {
+
+					renderPipeline.outputNode = v ? retroPipeline : defaultPass;
+					renderPipeline.needsUpdate = true;
+
+				} ).name( 'Retro Pipeline' );
+
+				gui.add( curvature, 'value', 0, 0.2, 0.01 ).name( 'Curvature' );
+				gui.add( colorDepthSteps, 'value', 4, 32, 1 ).name( 'Color Depth' );
+				gui.add( scanlineIntensity, 'value', 0, 1, 0.01 ).name( 'Scanlines' );
+				gui.add( scanlineCount, 'value', 20, 480, 1 ).name( 'Scanline Count' );
+				gui.add( scanlineSpeed, 'value', 0, .1, 0.01 ).name( 'Scanline Speed' );
+				gui.add( vignetteIntensity, 'value', 0, 1, 0.01 ).name( 'Vignette' );
+				gui.add( bleeding, 'value', 0, 0.005, 0.001 ).name( 'Color Bleeding' );
+				gui.add( affineDistortion, 'value', 0, 1 ).name( 'Affine Distortion' );
+
+				window.addEventListener( 'resize', onWindowResize );
+
+			}
+
+			function onWindowResize() {
+
+				camera.aspect = window.innerWidth / window.innerHeight;
+				camera.updateProjectionMatrix();
+
+				renderer.setSize( window.innerWidth, window.innerHeight );
+
+			}
+
+			async function animate() {
+
+				controls.update();
+
+				renderPipeline.render();
+
+			}
+
+		</script>
+	</body>
+</html>

+ 0 - 1
src/nodes/Nodes.js

@@ -83,7 +83,6 @@ export { default as ColorSpaceNode } from './display/ColorSpaceNode.js';
 export { default as FrontFacingNode } from './display/FrontFacingNode.js';
 export { default as NormalMapNode } from './display/NormalMapNode.js';
 export { default as PassNode } from './display/PassNode.js';
-export { default as PosterizeNode } from './display/PosterizeNode.js';
 export { default as RenderOutputNode } from './display/RenderOutputNode.js';
 export { default as ScreenNode } from './display/ScreenNode.js';
 export { default as ToneMappingNode } from './display/ToneMappingNode.js';

+ 0 - 1
src/nodes/TSL.js

@@ -99,7 +99,6 @@ export * from './display/ColorAdjustment.js';
 export * from './display/ColorSpaceNode.js';
 export * from './display/FrontFacingNode.js';
 export * from './display/NormalMapNode.js';
-export * from './display/PosterizeNode.js';
 export * from './display/ToneMappingNode.js';
 export * from './display/ScreenNode.js';
 export * from './display/ViewportTextureNode.js';

+ 17 - 0
src/nodes/display/ColorAdjustment.js

@@ -139,3 +139,20 @@ export const cdl = /*@__PURE__*/ Fn( ( [
 	return vec4( v.rgb, color.a );
 
 } );
+
+/**
+ * TSL function for creating a posterize effect which reduces the number of colors
+ * in an image, resulting in a more blocky and stylized appearance.
+ *
+ * @tsl
+ * @function
+ * @param {Node} sourceNode - The input color.
+ * @param {Node} stepsNode - Controls the intensity of the posterization effect. A lower number results in a more blocky appearance.
+ * @returns {Node} The posterized color.
+ */
+export const posterize = Fn( ( [ source, steps ] ) => {
+
+	return source.mul( steps ).floor().div( steps );
+
+} );
+

+ 0 - 65
src/nodes/display/PosterizeNode.js

@@ -1,65 +0,0 @@
-import TempNode from '../core/TempNode.js';
-import { nodeProxy } from '../tsl/TSLBase.js';
-
-/**
- * Represents a posterize effect which reduces the number of colors
- * in an image, resulting in a more blocky and stylized appearance.
- *
- * @augments TempNode
- */
-class PosterizeNode extends TempNode {
-
-	static get type() {
-
-		return 'PosterizeNode';
-
-	}
-
-	/**
-	 * Constructs a new posterize node.
-	 *
-	 * @param {Node} sourceNode - The input color.
-	 * @param {Node} stepsNode - Controls the intensity of the posterization effect. A lower number results in a more blocky appearance.
-	 */
-	constructor( sourceNode, stepsNode ) {
-
-		super();
-
-		/**
-		 * The input color.
-		 *
-		 * @type {Node}
-		 */
-		this.sourceNode = sourceNode;
-
-		/**
-		 * Controls the intensity of the posterization effect. A lower number results in a more blocky appearance.
-		 *
-		 * @type {Node}
-		 */
-		this.stepsNode = stepsNode;
-
-	}
-
-	setup() {
-
-		const { sourceNode, stepsNode } = this;
-
-		return sourceNode.mul( stepsNode ).floor().div( stepsNode );
-
-	}
-
-}
-
-export default PosterizeNode;
-
-/**
- * TSL function for creating a posterize node.
- *
- * @tsl
- * @function
- * @param {Node} sourceNode - The input color.
- * @param {Node} stepsNode - Controls the intensity of the posterization effect. A lower number results in a more blocky appearance.
- * @returns {PosterizeNode}
- */
-export const posterize = /*@__PURE__*/ nodeProxy( PosterizeNode ).setParameterLength( 2 );

+ 1 - 1
src/nodes/utils/RTTNode.js

@@ -266,7 +266,7 @@ export default RTTNode;
  * @param {Object} [options={type:HalfFloatType}] - The options for the internal render target.
  * @returns {RTTNode}
  */
-export const rtt = ( node, ...params ) => nodeObject( new RTTNode( nodeObject( node ), ...params ) );
+export const rtt = ( node, ...params ) => new RTTNode( nodeObject( node ), ...params );
 
 /**
  * TSL function for converting nodes to textures nodes.

+ 4 - 2
src/nodes/utils/UVUtils.js

@@ -16,14 +16,16 @@ import { context } from '../core/ContextNode.js';
  *
  * @tsl
  * @function
- * @param {function(Node):Node<vec2>} callback - A callback that receives the texture node
+ * @param {function(Node):Node<vec2>|Node<vec2>} callback - A callback that receives the texture node
  * and must return the new uv coordinates.
  * @param {Node} [node=null] - An optional node to which the context will be applied.
  * @return {ContextNode} A context node that replaces the default UV coordinates.
  */
 export function replaceDefaultUV( callback, node = null ) {
 
-	return context( node, { getUV: callback } );
+	const getUV = typeof callback === 'function' ? callback : () => callback;
+
+	return context( node, { getUV } );
 
 }
 

粤ICP备19079148号