Skip to content

Conversation

@NateSmyth
Copy link

Description

Streamlines storage texture read/write operations in TSL, enabling patterns like ping-pong compute shaders without raw WGSL.

Problems

Initially based on trying to implement a ping-pong type compute shader setup based on examples/webgpu_compute_texture_pingpong.html, but using "pure" TSL, i.e. port the WGSL compute strings to native TSL methods. But:

  • clone loses access: .load() path calls .clone, which doesn't preserve access. Defaults to write-only.

  • invalid textureLoad() signature for storage textures: when passing a storage texture to generateTextureLoad(), it does:

if (  levelSnippet === null ) levelSnippet = '0u'

...which is fine for regular textures but the WGSL textureLoad() doesn't accept a levels parameter for storage textures.

  • passing nodes to textureStore(): for this ping-pong type example it ends up being a lot more ergonomic to build out the read/write nodes first then pass them to textureStore() during compute. But out-of-the-box that doesn't work because end up with a full node for the value.

Also, incidentally, I noticed while doing this that the original webgpu_compute_texture_pingpong example doesn't work in Firefox because naga complains about writeTex not being a global binding. More of a naga issue but using TSL sidesteps this entirely.

Changes

One line update to storageTextureNode.clone to preserve access

Some checks in generateTextureLoad() to match the WGSL textureLoad signature when isStorageTexture is true

Compatibility path in textureStore() to allow an existing storageTextureNode to be passed (without overwriting that node)

Ported ping-pong example to use this pattern

Added some basic validation tests

Before

const readPing = storageTexture( pingTexture ).setAccess( NodeAccess.READ_ONLY );
				const writePing = storageTexture( pingTexture ).setAccess( NodeAccess.WRITE_ONLY );
				const readPong = storageTexture( pongTexture ).setAccess( NodeAccess.READ_ONLY );
				const writePong = storageTexture( pongTexture ).setAccess( NodeAccess.WRITE_ONLY );

				// compute init

				const rand2 = code( `
					fn rand2( n: vec2f ) -> f32 {

						return fract( sin( dot( n, vec2f( 12.9898, 4.1414 ) ) ) * 43758.5453 );

					}

					fn blur( image : texture_storage_2d<${wgslFormat}, read>, uv : vec2i ) -> vec4f {

						var color = vec4f( 0.0 );

						color += textureLoad( image, uv + vec2i( - 1, 1 ));
						color += textureLoad( image, uv + vec2i( - 1, - 1 ));
						color += textureLoad( image, uv + vec2i( 0, 0 ));
						color += textureLoad( image, uv + vec2i( 1, - 1 ));
						color += textureLoad( image, uv + vec2i( 1, 1 ));

						return color / 5.0; 
					}

					fn getUV( posX: u32, posY: u32 ) -> vec2f {

						let uv = vec2f( f32( posX ) / ${ width }.0, f32( posY ) / ${ height }.0 );

						return uv;

					}
				` );
				//etc

After

  		const rand2 = Fn(([n]) => {

  			return n.dot(vec2(12.9898, 4.1414)).sin().mul(43758.5453).fract();

  		});

  		const writePing = storageTexture(pingTexture).setAccess(NodeAccess.WRITE_ONLY);
  		const readPing = storageTexture(pingTexture).setAccess(NodeAccess.READ_ONLY);
  		const writePong = storageTexture(pongTexture).setAccess(NodeAccess.WRITE_ONLY);
  		const readPong = storageTexture(pongTexture).setAccess(NodeAccess.READ_ONLY);

  		const computeInit = Fn(() => {

  			const posX = instanceIndex.mod(width);
  			const posY = instanceIndex.div(width);
  			const indexUV = uvec2(posX, posY);
  			const uv = vec2(float(posX).div(width), float(posY).div(height));

  			const r = rand2(uv.add(seed.mul(100))).sub(rand2(uv.add(seed.mul(300))));
  			const g = rand2(uv.add(seed.mul(200))).sub(rand2(uv.add(seed.mul(300))));
  			const b = rand2(uv.add(seed.mul(200))).sub(rand2(uv.add(seed.mul(100))));

  			textureStore(writePing, indexUV, vec4(r, g, b, 1));

  		});

  		computeInitNode = computeInit().compute(width * height);

  		// compute ping-pong: blur function using .load() for textureLoad
  		const blur = Fn(([readTex, uv]) => {

  			const c0 = readTex.load(uv.add(ivec2(- 1, 1)));
  			const c1 = readTex.load(uv.add(ivec2(- 1, - 1)));
  			const c2 = readTex.load(uv.add(ivec2(0, 0)));
  			const c3 = readTex.load(uv.add(ivec2(1, - 1)));
  			const c4 = readTex.load(uv.add(ivec2(1, 1)));

  			return c0.add(c1).add(c2).add(c3).add(c4).div(5.0);

  		});
---

Unit tests pass, lints clean, etc. Example verified working in chromium and Firefox.
Can work around some of this texture(tex, uv) + NearestFilter + some normalization math but the PR method feels a lot more TSL-semantic.

@github-actions
Copy link

github-actions bot commented Jan 12, 2026

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
WebGL 355.41
84.51
355.41
84.51
+0 B
+0 B
WebGPU 621.21
172.51
621.4
172.58
+190 B
+66 B
WebGPU Nodes 619.82
172.27
620
172.34
+190 B
+65 B

🌳 Bundle size after tree-shaking

Minimal build including a renderer, camera, empty scene, and dependencies.

Before After Diff
WebGL 487.36
119.29
487.36
119.29
+0 B
+0 B
WebGPU 691.86
187.79
691.97
187.81
+103 B
+21 B
WebGPU Nodes 641.66
174.96
641.77
174.98
+103 B
+20 B

@NateSmyth NateSmyth marked this pull request as ready for review January 12, 2026 18:51
@mrdoob
Copy link
Owner

mrdoob commented Jan 13, 2026

Can you revert the indentation changes in webgpu_compute_texture_pingpong?

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR streamlines storage texture read/write operations in TSL (Three.js Shading Language), enabling ping-pong compute shader patterns without requiring raw WGSL code. It addresses three core issues: clone not preserving access modes, incorrect textureLoad signature for storage textures, and inability to pass storage texture nodes to textureStore.

Changes:

  • Fixed StorageTextureNode.clone() to preserve the access property, enabling .load() to work correctly on storage textures with read access
  • Updated WGSLNodeBuilder.generateTextureLoad() to omit the level parameter for storage textures, matching WGSL specification requirements
  • Enhanced textureStore() to accept existing StorageTextureNode instances, allowing pre-configured nodes with specific access modes to be passed directly

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/nodes/accessors/StorageTextureNode.js Added access preservation in clone method and compatibility path in textureStore for accepting StorageTextureNode instances
src/renderers/webgpu/nodes/WGSLNodeBuilder.js Modified generateTextureLoad to conditionally omit level parameter for storage textures per WGSL spec
examples/webgpu_compute_texture_pingpong.html Converted from raw WGSL strings to pure TSL implementation demonstrating the new storage texture read/write capabilities
test/unit/src/nodes/accessors/StorageTextureNode.tests.js Added tests verifying clone preserves access properties for READ_ONLY and READ_WRITE modes
test/unit/src/renderers/webgpu/nodes/WGSLNodeBuilder.tests.js Added tests verifying generateTextureLoad correctly handles storage vs regular textures with level parameters
test/unit/three.source.unit.js Registered new test files for StorageTextureNode and WGSLNodeBuilder

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

import * as THREE from 'three/webgpu';
import { storageTexture, wgslFn, code, instanceIndex, uniform, NodeAccess } from 'three/tsl';
import * as THREE from 'three/webgpu';
import { storageTexture, textureStore, Fn, instanceIndex, uniform, float, vec2, vec4, uvec2, ivec2, int, uint, NodeAccess } from 'three/tsl';
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import uint is unused in this example. Consider removing it to keep the imports clean.

Suggested change
import { storageTexture, textureStore, Fn, instanceIndex, uniform, float, vec2, vec4, uvec2, ivec2, int, uint, NodeAccess } from 'three/tsl';
import { storageTexture, textureStore, Fn, instanceIndex, uniform, float, vec2, vec4, uvec2, ivec2, int, NodeAccess } from 'three/tsl';

Copilot uses AI. Check for mistakes.
* @param {?Node} [storeNode=null] - The value node that should be stored in the texture.
* @returns {StorageTextureNode}
*/
export const textureStore = ( value, uvNode, storeNode ) => {
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function documentation lacks clarity about its dual behavior: it can accept either a StorageTexture or a StorageTextureNode as the first parameter. When a StorageTextureNode is passed, it clones the node and updates the uvNode and storeNode properties, preserving the access mode. This important behavior should be documented to help API users understand when to use this pattern versus storageTexture().

Copilot uses AI. Check for mistakes.
Comment on lines +261 to +266
if ( value.isStorageTextureNode === true ) {

// Derive new storage texture node from existing one
node = value.clone();
node.uvNode = uvNode;
node.storeNode = storeNode;
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new functionality in textureStore() that allows passing a StorageTextureNode (lines 261-266) lacks test coverage. Consider adding a test that verifies the behavior when value.isStorageTextureNode === true, ensuring that the access mode is preserved and the uvNode and storeNode are correctly updated.

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +61
QUnit.test( 'generateTextureLoad omits level for storage textures', ( assert ) => {

const context = {
renderer: { backend: { compatibilityMode: false } }
};

const storageTexture = { isStorageTexture: true };

const snippet = WGSLNodeBuilder.prototype.generateTextureLoad.call(
context,
storageTexture,
'testTexture',
'uvec2(0, 0)',
null, // levelSnippet
null, // depthSnippet
null // offsetSnippet
);

// Storage textures should NOT have level parameter (WGSL spec)
assert.notOk( snippet.includes( 'u32(' ), 'storage texture load should not include level parameter' );
assert.strictEqual( snippet, 'textureLoad( testTexture, uvec2(0, 0) )', 'correct WGSL for storage texture' );

} );

QUnit.test( 'generateTextureLoad includes level for regular textures', ( assert ) => {

const context = {
renderer: { backend: { compatibilityMode: false } }
};

const regularTexture = { isStorageTexture: false };

const snippet = WGSLNodeBuilder.prototype.generateTextureLoad.call(
context,
regularTexture,
'testTexture',
'uvec2(0, 0)',
null, // levelSnippet - should default to '0u'
null,
null
);

// Regular textures SHOULD have level parameter
assert.ok( snippet.includes( 'u32( 0u )' ), 'regular texture load should include default level parameter' );
assert.strictEqual( snippet, 'textureLoad( testTexture, uvec2(0, 0), u32( 0u ) )' );

} );
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests for generateTextureLoad cover the basic cases without depthSnippet, but the code also has special handling for storage textures when depthSnippet is provided (lines 562-564 in WGSLNodeBuilder.js). Consider adding a test case that verifies storage textures also omit the level parameter when a depthSnippet is provided.

Copilot uses AI. Check for mistakes.
@NateSmyth NateSmyth force-pushed the load-storageTexture branch from ea44c29 to 17ae820 Compare January 13, 2026 01:33
@NateSmyth NateSmyth force-pushed the load-storageTexture branch from 17ae820 to b596349 Compare January 13, 2026 02:03
@sunag sunag added this to the r183 milestone Jan 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants