Struggling to recreate WebGL ping-pong buffers with WebGPU and TSL

Hi,
I try to translate this code:
ogl/src/extras/Flowmap.js at master · oframe/ogl · GitHub
ogl/examples/mouse-flowmap.html at master · oframe/ogl
to WebGPU and TSL on my sveltekit + threlte project
When outputting the flow, here is the result I should get:

flow

But here is what I get instead:

Enregistrement2025-10-11102350-ezgif.com-resize

(demo updated as we go)

Any help would be appreciated, thanks !

2 Likes

The weirdest thing is that the flow stamp is kind of duplicated symmetrically along the X-axis, as you can see

@Mugen87 Your help would be greatly appreciated !

I don’t have the bandwidth to fix the effect but here are some things you have to be aware of:

The effect makes the assumption to run in a fullscreen triangle with a uv-range of [0,1]. Your ScreenGeometry class uses [0,2] (this is why you see this duplication effect). I suggest you use QuadMesh for rendering the fullscreen effect so this bit will be automatically right.

Create it like so:

private _quadMesh = new QuadMesh();

In init():

this._quadMesh.material = this._material;

Your update method looks like so then:

renderer.setRenderTarget(this._mask.write);
_quadMesh.render(renderer);
renderer.setRenderTarget(lastTarget);

Besides, the uv convention between WebGPURenderer and OGL is different. The uv origin (0,0) is top-left in WebGPU whereas in WebGL it is bottom-left. You have to honor this in your cursor/location computation otherwise the effect won’t be right. At least the location uniform value should be:

flowmap.location.value.set(x / $size.width, y / $size.height);
5 Likes

Thanks a lot for taking the time to respond ! I followed your suggestion, and the duplication problem is gone. However, there’s still something that needs to be fixed, it doesn’t look quite the way I expect, though I’m not really sure why yet.

1 Like

I did some research, but unfortunately, I really have no idea what to do to get this working :confused:

I had some to come up with different approach which produces a similar result. The original code confused me a bit so I have implemented the shape drawing differently.

If you are interested in how the circle drawing works, you can study the GLSL from this shader toy demo. https://www.shadertoy.com/view/Md23DV

The tutorial provides multiple code snippets for shape rendering.

Just for the case StackBlitz didn’t save my code, here is a copy of `Flowmap.ts`.

import { useThrelte } from '@threlte/core';
import {
  Mesh,
  Vector2,
  NodeMaterial,
  OrthographicCamera,
  RenderTarget,
  WebGPURenderer,
  QuadMesh,
} from 'three/webgpu';
import {
  float,
  Fn,
  min,
  pow,
  smoothstep,
  texture,
  uniform,
  uv,
  vec2,
  vec3,
  mix,
} from 'three/tsl';
import { ScreenGeometry } from './ScreenGeometry';
import { onDestroy } from 'svelte';

type FlowmapOptions = {
  size?: number;
  falloff?: number;
  alpha?: number;
  dissipation?: number;
};

export class Flowmap {
  private _threlte = useThrelte<WebGPURenderer>();
  private _texture = texture();
  private _location = uniform(new Vector2(-1));
  private _velocity = uniform(new Vector2());
  private _aspect = uniform(float(1));
  private _material = new NodeMaterial();
  private _radius = uniform(float(0.1));
  private _mesh = new QuadMesh(this._material);
  private _mask: {
    read: RenderTarget;
    write: RenderTarget;
  };
  private _options: Readonly<Required<FlowmapOptions>>;

  constructor(options: FlowmapOptions = {}) {
    this._options = Object.freeze({
      size: 128,
      falloff: 0.3,
      alpha: 1,
      dissipation: 0.7,
      ...options,
    });
    this._mask = {
      read: new RenderTarget(this._options.size, this._options.size),
      write: new RenderTarget(this._options.size, this._options.size),
    };

    this._init();
  }

  private _init() {
    this._material.colorNode = this._colorNode();
    this._material.depthTest = false;
    this._material.depthWrite = false;
    // this._material.transparent = true

    this._swap();

    onDestroy(() => {
      this._mask.read.dispose();
      this._mask.write.dispose();
      this._material.dispose();
    });
  }

  update() {
    const { renderer } = this._threlte;
    const lastTarget = renderer.getRenderTarget();

    renderer.setRenderTarget(this._mask.write);
    this._mesh.render(renderer);
    renderer.setRenderTarget(lastTarget);

    this._swap();
  }

  get texture() {
    return this._texture;
  }

  get location() {
    return this._location;
  }

  get velocity() {
    return this._velocity;
  }

  get aspect() {
    return this._aspect;
  }
  get radius() {
    return this._radius;
  }

  private _swap() {
    const temp = this._mask.read;

    this._mask.read = this._mask.write;
    this._mask.write = temp;
    this._texture.value = this._mask.read.texture;
  }

  private _circle = Fn(([uv, center, radius]) => {
    const position = uv.sub(center);
    position.x.mulAssign(this._aspect);
    // depending on how you define the first two paramters, the circle gets more hard of soft edges
    return smoothstep(
      radius.sub(0.05),
      radius.add(0.05),
      position.length()
    ).oneMinus();
  }).setLayout({
    name: 'circle',
    type: 'float',
    inputs: [
      { name: 'uv', type: 'vec2' },
      { name: 'center', type: 'vec2' },
      { name: 'radius', type: 'float' },
    ],
  });

  private _colorNode = Fn(() => {
    const color = this._texture.mul(this._options.dissipation).toVar();

    // define the color of the circle based on the pointer's velocity

    const circleColor = vec3(
      this._velocity.mul(vec2(1, -1)),
      float(1).sub(pow(float(1).sub(min(float(1), this._velocity.length())), 3))
    );

    // mix the base color with the circle color.

    color.rgb = mix(
      color.rgb,
      circleColor,
      this._circle(uv(), this._location, this.radius)
    );

    return color;
  });
}