b3d-svg-plane

A plane mesh textured with SVG content. Supports static SVG from a URL or dynamic SVG from a live DOM element (updated on a timer, ideal for tosijs-bound HUDs and instrument panels).

Pointer hits on the mesh are mapped back to synthetic PointerEvents on the source SVG element using UV coordinates, so interactive SVG UIs work in 3D/XR.

Example — static SVG on a plane

import { b3d, b3dSvgPlane, b3dLight } from 'tosijs-3d'

const scene = b3d(
  {
    sceneCreated(el, BABYLON) {
      const camera = new BABYLON.ArcRotateCamera(
        'cam', -Math.PI / 2, Math.PI / 3, 5,
        new BABYLON.Vector3(0, 0, 0), el.scene
      )
      camera.attachControl(el.querySelector('canvas'), true)
      el.setActiveCamera(camera)
    },
  },
  b3dLight({ intensity: 1 }),
  b3dSvgPlane({
    url: '/tosi-test-pattern.svg',
    width: 2,
    height: 2,
    materialChannel: 'diffuse',
  }),
)

preview.append(scene)

Example — live dynamic SVG (radar display)

import { b3d, b3dSvgPlane, b3dLight, SvgTexture } from 'tosijs-3d'
import { svgElements, tosi, xin } from 'tosijs'

const { svg, g, path, circle, polygon } = svgElements

// --- radar background ---
const outerRing = 'M128,8 C194.274,8,248,61.7258,248,128 C248,194.274,194.274,248,128,248 C61.7258,248,8.00001,194.274,8.00001,128 C8.00001,61.7258,61.7258,8,128,8 z'
const vLine = 'M128,53 C128,53,128,203,128,203'
const hRight = 'M203,128 C203,128,143,128,143,128'
const hLeft = 'M113,128 C113,128,53,128,53,128'
const guide = 'fill:#00a79e;fill-opacity:0.127;fill-rule:evenodd;stroke:#00a79e;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-width:4;'
const axis = guide + 'stroke-opacity:0.24;'

// --- blip spawning ---
let nextId = 0
const RANGE = 115

function spawnFriendly() {
  const angle = Math.random() * Math.PI * 2
  const heading = angle + Math.PI * (0.6 + Math.random() * 0.8)
  const speed = 0.2 + Math.random() * 0.3
  return {
    id: nextId++,
    x: 128 + Math.cos(angle) * 105, y: 128 + Math.sin(angle) * 105,
    dx: Math.cos(heading) * speed, dy: Math.sin(heading) * speed,
  }
}

function spawnHostile() {
  const angle = Math.random() * Math.PI * 2
  const heading = angle + Math.PI * (0.7 + Math.random() * 0.6)
  const speed = 0.5 + Math.random() * 0.6
  return {
    id: nextId++,
    x: 128 + Math.cos(angle) * 110, y: 128 + Math.sin(angle) * 110,
    dx: Math.cos(heading) * speed, dy: Math.sin(heading) * speed,
  }
}

const { friendlies, hostiles } = tosi({
  friendlies: Array.from({ length: 6 }, spawnFriendly),
  hostiles: Array.from({ length: 4 }, spawnHostile),
})

const position = (el, item) => {
  if (item) el.setAttribute('transform', `translate(${item.x},${item.y})`)
}

const friendlyLayer = g(
  g(
    circle({ r: '5', fill: 'none', stroke: '#8cc63f', 'stroke-width': '1' }),
    { bind: { value: '^', binding: position } }
  ),
  { bindList: { value: friendlies, idPath: 'id' } }
)
const hostileLayer = g(
  g(
    polygon({ points: '0,-6 5.2,3 -5.2,3', fill: 'none', stroke: '#ff1d25', 'stroke-width': '1.5', 'stroke-linejoin': 'round' }),
    { bind: { value: '^', binding: position } }
  ),
  { bindList: { value: hostiles, idPath: 'id' } }
)

const radarSvg = svg(
  { width: '256', height: '256', viewBox: '0 0 256 256',
    style: 'position:absolute;left:-9999px' },
  g(
    path({ style: guide + 'stroke-opacity:0.5;', d: outerRing }),
    path({ style: axis, d: vLine }),
    path({ style: axis, d: hRight }),
    path({ style: axis, d: hLeft }),
  ),
  friendlyLayer,
  hostileLayer,
)
preview.append(radarSvg)

function tick(arr) {
  const kept = []
  for (const b of arr) {
    const nx = b.x + b.dx, ny = b.y + b.dy
    if (Math.sqrt((nx - 128) ** 2 + (ny - 128) ** 2) < RANGE) {
      kept.push({ ...b, x: nx, y: ny })
    }
  }
  return kept
}
// Deliberately rate-limited to ~15fps. Each tick mutates tosi bindings →
// re-renders the SVG → re-rasterizes the texture, so a fast tick is a real cost
// (especially on a textured plane in XR). 15fps is plenty for a radar.
setInterval(() => {
  const f = tick(xin.friendlies)
  if (Math.random() < 0.06) f.push(spawnFriendly())
  xin.friendlies = f

  const h = tick(xin.hostiles)
  if (Math.random() < 0.04) h.push(spawnHostile())
  xin.hostiles = h
}, 66)

const scene = b3d(
  {
    sceneCreated(el, BABYLON) {
      const camera = new BABYLON.ArcRotateCamera(
        'cam', -Math.PI / 2, Math.PI / 3, 5,
        new BABYLON.Vector3(0, 0, 0), el.scene
      )
      camera.attachControl(el.querySelector('canvas'), true)
      el.setActiveCamera(camera)

      const tex = new SvgTexture({
        scene: el.scene,
        element: radarSvg,
        resolution: 512,
        updateInterval: 66, // ~15fps — matches the sim tick; no point rendering faster
      })

      const plane = BABYLON.MeshBuilder.CreatePlane(
        'hud', { width: 2, height: 2, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, el.scene
      )
      const mat = new BABYLON.StandardMaterial('hud-mat', el.scene)
      mat.emissiveTexture = tex.texture
      mat.diffuseColor = BABYLON.Color3.Black()
      mat.disableLighting = true
      mat.opacityTexture = tex.texture
      plane.material = mat
    },
  },
  b3dLight({ intensity: 1 }),
)

preview.append(scene)

Example — dynamic interactive SVG with pointer events

import { b3d, b3dLight, SvgTexture } from 'tosijs-3d'
import { svgElements } from 'tosijs'

const { svg, rect, text, g } = svgElements

let count = 0
const label = text({
  x: 100, y: 70, 'text-anchor': 'middle', fill: 'white',
  'font-size': 32, 'font-family': 'sans-serif',
})
label.textContent = 'Clicks: 0'

const btnRect = rect({
  x: 25, y: 110, width: 150, height: 50, rx: 8,
  fill: '#07a',
})
const btnLabel = text({
  x: 100, y: 143, 'text-anchor': 'middle', fill: 'white',
  'font-size': 20, 'font-family': 'sans-serif',
})
btnLabel.textContent = 'Click me'
const btn = g(btnRect, btnLabel)

btn.addEventListener('pointerenter', () => { btnRect.setAttribute('fill', '#09c') })
btn.addEventListener('pointerleave', () => { btnRect.setAttribute('fill', '#07a') })
btn.addEventListener('pointerdown', () => { btnRect.setAttribute('fill', '#0bf') })
btn.addEventListener('pointerup', () => {
  btnRect.setAttribute('fill', '#09c')
  count++
  label.textContent = 'Clicks: ' + count
})

const uiSvg = svg(
  { width: 200, height: 200, viewBox: '0 0 200 200',
    style: 'position:absolute;top:8px;right:8px;z-index:1;pointer-events:auto;cursor:pointer' },
  rect({ width: 200, height: 200, rx: 12, fill: '#222' }),
  label,
  btn,
)

// Button hit rect in SVG coordinates
const BTN = { x: 25, y: 110, w: 150, h: 50 }
function inBtn(sx, sy) {
  return sx >= BTN.x && sx <= BTN.x + BTN.w && sy >= BTN.y && sy <= BTN.y + BTN.h
}

const scene = b3d(
  {
    sceneCreated(el, BABYLON) {
      const camera = new BABYLON.ArcRotateCamera(
        'cam', -Math.PI / 2, Math.PI / 3, 5,
        new BABYLON.Vector3(0, 0, 0), el.scene
      )
      camera.attachControl(el.querySelector('canvas'), true)
      el.setActiveCamera(camera)

      const tex = new SvgTexture({
        scene: el.scene,
        element: uiSvg,
        resolution: 512,
        updateInterval: 100,
      })

      const plane = BABYLON.MeshBuilder.CreatePlane(
        'ui', { width: 2, height: 2, sideOrientation: BABYLON.Mesh.DOUBLESIDE }, el.scene
      )
      const mat = new BABYLON.StandardMaterial('ui-mat', el.scene)
      mat.emissiveTexture = tex.texture
      mat.opacityTexture = tex.texture
      mat.diffuseColor = BABYLON.Color3.Black()
      mat.disableLighting = true
      plane.material = mat

      el.scene.constantlyUpdateMeshUnderPointer = true
      let wasOverBtn = false
      el.scene.onPointerObservable.add((pointerInfo) => {
        const { POINTERDOWN, POINTERUP, POINTERMOVE } = BABYLON.PointerEventTypes
        if (pointerInfo.type !== POINTERDOWN &&
            pointerInfo.type !== POINTERUP &&
            pointerInfo.type !== POINTERMOVE) return
        const pick = pointerInfo.pickInfo
        if (!pick?.hit || pick.pickedMesh !== plane) {
          if (wasOverBtn) { btn.dispatchEvent(new PointerEvent('pointerleave')); wasOverBtn = false }
          return
        }
        const uv = pick.getTextureCoordinates()
        if (!uv) return

        const svgX = uv.x * 200
        const svgY = (1 - uv.y) * 200
        const over = inBtn(svgX, svgY)

        if (over && !wasOverBtn) btn.dispatchEvent(new PointerEvent('pointerenter'))
        if (!over && wasOverBtn) btn.dispatchEvent(new PointerEvent('pointerleave'))
        wasOverBtn = over

        if (!over) return
        const type = pointerInfo.type === POINTERDOWN ? 'pointerdown'
          : pointerInfo.type === POINTERUP ? 'pointerup' : 'pointermove'
        if (type !== 'pointermove') btn.dispatchEvent(new PointerEvent(type))
      })
    },
  },
  b3dLight({ intensity: 1 }),
)
scene.style.position = 'relative'
scene.append(uiSvg)

preview.append(scene)
// b3d shows an Enter-VR button automatically when an immersive-vr session is
// supported — try this same button with a controller in VR.

The SVG overlay is interactive in 2D (click it directly) and the same events fire when you click the 3D plane — both update the same counter because they share the same DOM element. The SVG doesn't need to be visible — it can be hidden offscreen (left:-9999px) or even display:none and the texture still renders, since SvgTexture clones the element and serializes its markup independently.

How it works

SVG → Texture pipeline

SvgTexture renders SVG content onto a Babylon.js DynamicTexture via an offscreen canvas:

  1. SerializeXMLSerializer.serializeToString() captures the live SVG DOM (including any tosijs binding changes) as an XML string.
  2. Blob URL — the XML is wrapped in a Blob with type image/svg+xml and turned into an object URL.
  3. Image decode — a reusable Image element loads the blob URL. On load, the image is drawn onto the DynamicTexture's canvas with a Y-flip (ctx.translate(0, h); ctx.scale(1, -1)) because Babylon UV origin is bottom-left while SVG origin is top-left.
  4. GPU uploaddt.update(false) pushes the canvas pixels to the GPU.

A _rendering guard prevents overlapping async renders. The Image and canvas are reused across frames — only the Blob is recreated each cycle (and immediately revoked after decode).

In static mode (url), a plain BABYLON.Texture is used instead and no polling occurs.

Emissive material for self-lit displays

For HUDs and panels you typically want the texture at full brightness regardless of scene lighting. The pattern is:

Pointer event pass-through

The demo above maps 3D pointer picks back to synthetic PointerEvents on the SVG DOM:

  1. scene.constantlyUpdateMeshUnderPointer = true enables hover tracking.
  2. scene.onPointerObservable fires on move/down/up.
  3. pickInfo.getTextureCoordinates() gives UV (0–1) at the hit point.
  4. UV is mapped to SVG coordinates: svgX = uv.x * svgWidth, svgY = (1 - uv.y) * svgHeight (Y flip for SVG's top-left origin).
  5. A rect-hull hit test determines which SVG element is under the pointer.
  6. Synthetic PointerEvents (pointerenter, pointerleave, pointerdown, pointerup) are dispatched on the target element — the same events that work in a regular 2D SVG UI.

This means you can build and test SVG UIs with standard DOM event listeners in a conventional web page, then project them onto 3D surfaces or into XR/AR scenes — the same code works across all contexts.

The demo uses simple rect-hull hit testing for the button, which is sufficient for rectangular controls. For finer-grained hit testing (irregular shapes, overlapping elements), the mapped SVG coordinates (svgX, svgY) are available — you can use them with document.elementFromPoint() if the SVG is positioned in the viewport, or implement your own shape-specific point-in-polygon tests.

Attributes

Attribute Default Description
width 1 Plane width in scene units
height 1 Plane height
resolution 512 Texture resolution (square, px)
url '' SVG URL — fetched and rendered once
updateInterval 30 Re-render interval in ms (dynamic mode)
materialChannel 'emissive' 'emissive' (unlit) or 'diffuse' (lit)
cameraRelative false Parent plane to active camera (HUD mode)
pointerEvents true Map 3D pick hits → SVG pointer events
doubleSided true Render both faces

Set the svgElement property to a live SVG element for dynamic mode.