Skip to content

feat: add renderer method .copyBufferToBuffer()#33044

Open
thelazylamaGit wants to merge 2 commits intomrdoob:devfrom
thelazylamaGit:copyBufferToBuffer
Open

feat: add renderer method .copyBufferToBuffer()#33044
thelazylamaGit wants to merge 2 commits intomrdoob:devfrom
thelazylamaGit:copyBufferToBuffer

Conversation

@thelazylamaGit
Copy link

@thelazylamaGit thelazylamaGit commented Feb 22, 2026

Description

Exposes webGPU api's copyBufferToBuffer() method, much like how copyTextureToTexture() and others are currently exposed.

For the webGL fallback it uses gl.copyBufferSubData as the equivalent, however, I haven't properly tested this yet as im not too familiar with webGL and my main use case for this feature is when using compute shaders with webGPU.

The backend functions are pretty light wrappers over the native api and accept bytes whilst the exposed method on the renderer takes in items and automatically converts to the correct amount of bytes based on the itemSize so users don't have to deal with byte conversions.

I've begun working on a physarum slime mold simulation and added this feature as I wanted a way to dynamically increase my particle buffer size without just setting a large maximum buffer size and early returning. I currently use a similar method for resizing storageTextures using renderer.copyTextureToTexture() wherein I resize the texture and copy the old contents over.

      renderer.setSize(x, y, false)

      // Rebuild every texture on my texture object
      Object.entries(tex).forEach(([key, tex]) => {

        // create tmp texture at the old size
        const tmp = makeStorageTex(oldW, oldH, { type: tex.type ?? textureType })

        // copy contents to tmp
        copyTextureToTexture({ renderer: ctx.renderer, src: tex, dst: tmp, origin })

        // resize texture in place (same object -> kernels stay valid)
        tex.setSize(x, y, 1)
        tex.needsUpdate = true

        // copy old contents via tmp over to our newly sized texture
        copyTextureToTexture({ renderer: ctx.renderer, src: tmp, dst: tex, origin })

        tmp.dispose()
      })

The idea was to do the same but for my particle buffer using the added renderer.copyBufferToBuffer() method.

  const positionArray = instancedArray(controls.particleCount, 'vec2')
  const headingArray = instancedArray(controls.particleCount, 'vec2')

  let seedKernel = seedParticles().compute(controls.particleCount).setName('Seed Particles')
  let particleUpdateA = particleUpdate({ deposit: tex.a }).compute(controls.particleCount).setName('Update Particles A')
  let particleUpdateB = particleUpdate({ deposit: tex.b }).compute(controls.particleCount).setName('Update Particles B')
    
  function resizeStorageBuffer(newCount: number) {
    const oldPosAttr = positionArray.value
    const oldHeadAttr = headingArray.value
    const oldCount = oldPosAttr.count

    if (newCount <= 0) {
      mesh.count = 0
      return
    }

    // allocate new storage buffers
    const newPosAttr = new StorageInstancedBufferAttribute(newCount, 2)
    const newHeadAttr = new StorageInstancedBufferAttribute(newCount, 2)

    // copy old buffer over to new buffer
    renderer.copyBufferToBuffer(oldPosAttr, newPosAttr)
    renderer.copyBufferToBuffer(oldHeadAttr, newHeadAttr)

    // reassign the instancedArray's underlying BufferAttribute via .value
    positionArray.value = newPosAttr
    headingArray.value = newHeadAttr

    mesh.count = newCount

    // Dispose old kernels
    particleUpdateA.dispose()
    particleUpdateB.dispose()
    seedKernel.dispose()

    // Rebuild kernels with new count
    particleUpdateA = particleUpdate({ deposit: tex.a }).compute(newCount).setName('Update A')
    particleUpdateB = particleUpdate({ deposit: tex.b }).compute(newCount).setName('Update B')
    seedKernel = seedParticles().compute(newCount).setName('Seed Particles')

    // If added particles, give updated range random position and heading
    if (newCount > oldCount) {
      seedFrom.value = oldCount
      seedTo.value = newCount
      renderer.compute(seedKernel) // seeds only [oldCount, newCount)
    }
  }

Now this setup does work as expected, however, is quite inefficient because it constantly rebuilds the compute pipeline when ideally it should only rebuild the buffer's bind group. Also note the garbage collector does end up removing the old compute pipelines here but only because .dispose() is called before reassigning them, otherwise it would stack infinitely.

shapeMatch_hex-2-35-params_22026-02-22.22-34-13_AV1.webm

Now although this setup is quite inefficient, it behaves exactly as I want it to with old particles being copied over and new particles being added seamlessly. However, when attempting to rework the code so that it doesn't rebuild compute pipelines and instead uses a stable computeKernel node with a dyanmic dispatch size, it stops behaving as i'd like.

  const positionArray = instancedArray(controls.particleCount, 'vec2')
  const headingArray = instancedArray(controls.particleCount, 'vec2')

  const WG = 256
 
  const seedKernel = seedParticles().computeKernel([WG, 1, 1]).setName('Seed Particles')
  const particleUpdateA = particleUpdate({ deposit: tex.a }).computeKernel([WG, 1, 1]).setName('Update A')
  const particleUpdateB = particleUpdate({ deposit: tex.b }).computeKernel([WG, 1, 1]).setName('Update B')
  
  function resizeStorageBuffer(newCount: number) {
    const oldPosAttr = positionArray.value
    const oldHeadAttr = headingArray.value
    const oldCount = oldPosAttr.count

    if (newCount <= 0) {
      mesh.count = 0
      return
    }

    // allocate new storage buffers
    const newPosAttr = new StorageInstancedBufferAttribute(newCount, 2)
    const newHeadAttr = new StorageInstancedBufferAttribute(newCount, 2)

    // copy old buffer over to new buffer
    renderer.copyBufferToBuffer(oldPosAttr, newPosAttr)
    renderer.copyBufferToBuffer(oldHeadAttr, newHeadAttr)

    // reassign the instancedArray's underlying BufferAttribute via .value
    positionArray.value = newPosAttr
    headingArray.value = newHeadAttr

    mesh.count = newCount

    // If added particles, give updated range random position and heading
    if (newCount > oldCount) {
      seedFrom.value = oldCount
      seedTo.value = newCount
      renderer.compute(seedKernel, [Math.ceil(newCount / WG), 1, 1])
    }
  }
computeKernel2026-02-22.23-09-16_AV1.webm

In this case the computePipelines stay stable but it seems like the buffer reference isn't correctly updated or gets duplicated somehow? when setting particle count below the inital count of 10, it doubles it so that there are 4 working moving particles but then 4 more static particles. Even stranger yet is that when then running my renderer/texture resize logic the compute shaders do update to use the new buffer but the old buffer doesn't get copied over so the old particles just fade out.

I've tried all sorts of things in an attempt to get this to work such as

    newPosAttr.needsUpdate = true
    newHeadAttr.needsUpdate = true

    positionArray.value.needsUpdate = true
    headingArray.value.needsUpdate = true

    positionArray.needsUpdate = true
    headingArray.needsUpdate = true

    pointsMaterial.needsUpdate = true
    
    const dispatchNow = [Math.ceil(newCount / WG), 1, 1]
    particleUpdateA.setCount(dispatchNow)
    particleUpdateB.setCount(dispatchNow)
    seedKernel.setCount(dispatchNow)
    
    particleUpdateA.setCount(newCount)
    particleUpdateB.setCount(newCount)
    seedKernel.setCount(newCount)

    particleUpdateA.workgroupSize = dispatchNow 
    particleUpdateB.workgroupSize = dispatchNow 
    seedKernel.workgroupSize = dispatchNow 

    particleUpdateA.needsUpdate = true
    particleUpdateB.needsUpdate = true
    seedKernel.needsUpdate = true
    particleUpdateA.computeNode.needsUpdate = true
    particleUpdateB.computeNode.needsUpdate = true
    seedKernel.computeNode.needsUpdate = true

but to no avail

There was a pr that aimed at allowing storageBuffers to be reassigned dynamically #32847 but either it doesn't fully work here or im missing something in my implementation.

Overall, I think exposing a copyBufferToBuffer method would be useful and fit in nicely with the other exposed methods such as copyFramebufferToTexture and copyTextureToTexture, it just seems there needs to be some kinks worked out with how StorageBufferAttributes are cached and or referenced in compute functions. Im not very familiar with the intricacies of TSL's binding & cache system so any help here would be much appreciated, thanks.

@github-actions
Copy link

github-actions bot commented Feb 22, 2026

📦 Bundle size

Full ESM build, minified and gzipped.

Before After Diff
WebGL 359.17
85.24
359.17
85.24
+0 B
+0 B
WebGPU 626.25
174.04
628.55
174.73
+2.3 kB
+688 B
WebGPU Nodes 624.83
173.8
627.13
174.49
+2.3 kB
+688 B

🌳 Bundle size after tree-shaking

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

Before After Diff
WebGL 490.91
119.65
490.91
119.65
+0 B
+0 B
WebGPU 699.88
189.07
702.19
189.75
+2.31 kB
+683 B
WebGPU Nodes 649.09
176.43
651.39
177.11
+2.31 kB
+685 B

@thelazylamaGit thelazylamaGit marked this pull request as draft February 22, 2026 14:08
@thelazylamaGit thelazylamaGit marked this pull request as ready for review February 23, 2026 09:40
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.

1 participant