scena 1.7.1

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
import init, { SceneHost } from "../pkg/scena.js";

export async function buildSceneHost(canvas, leftUrl, rightUrl) {
  await init();

  const width = canvas.clientWidth || 640;
  const height = canvas.clientHeight || 480;
  const dpr = window.devicePixelRatio || 1;
  const host = await SceneHost.newWebgl2(canvas, width, height, dpr);
  const root = host.rootHandle();

  const leftFrame = host.addEmpty(
    root,
    [-0.6, 0.0, 0.0],
    [0.0, 0.0, 0.0, 1.0],
    [1.0, 1.0, 1.0],
    "frame:left",
  );
  const rightFrame = host.addEmpty(
    root,
    [0.6, 0.0, 0.0],
    [0.0, 0.0, 0.0, 1.0],
    [1.0, 1.0, 1.0],
    "frame:right",
  );

  const leftImportJson = await host.instantiateUrlUnderWithReportJson(leftFrame, leftUrl);
  const leftImport = JSON.parse(leftImportJson).import;
  const rightImport = await host.instantiateUrlUnder(rightFrame, rightUrl);
  const leftMesh = host.nodeHandle(leftImport, "ColoredTriangle");
  const rightMesh = host.nodeHandleByName(rightImport, "ColoredTriangle");

  return { host, leftFrame, rightFrame, leftMesh, rightMesh, leftImportJson };
}

export async function renderPushedFrame(state, poseByNode) {
  state.host.setTransforms(JSON.stringify(
    poseByNode.map(([node, transform]) => ({
      node,
      translation: transform.translation,
      rotation: transform.rotation,
      scale: transform.scale,
    })),
  ));
  state.host.setNodeAnnotation("left-label", state.leftMesh, [0.0, 0.0, 0.0]);
  state.host.frameAll();
  state.host.prepare();
  state.host.render();

  const capture = state.host.capture();
  return {
    inspection: JSON.parse(state.host.inspectJson()),
    capture: JSON.parse(capture.descriptorJson),
    annotationProjection: JSON.parse(state.host.annotationProjectionsJson()),
    rgba8: capture.rgba8,
  };
}

export async function configureRelease17Scene(state, animatedUrl, instancedUrl) {
  state.host.setAntiAliasing("fxaa");
  state.host.setBloom(JSON.stringify({
    threshold_srgb: 190,
    intensity: 0.35,
    radius_px: 4,
  }));
  state.host.setAmbientOcclusion(JSON.stringify({
    radius_px: 5,
    intensity: 0.45,
    depth_threshold: 0.025,
  }));

  const instanceRoots = await state.host.instantiateUrlInstancedUnder(
    state.leftFrame,
    instancedUrl,
    3,
  );
  state.host.setTransformsEasedTyped(
    BigUint64Array.from(instanceRoots.map(BigInt)),
    new Float32Array([
      -0.45, -0.25, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0,
       0.00, -0.25, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0,
       0.45, -0.25, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0,
    ]),
    0.0,
    "linear",
  );
  state.host.setVisible(instanceRoots[0], false);
  state.host.setNodeTint(instanceRoots[1], 0.1, 0.8, 0.25, 1.0);

  const animatedImport = await state.host.instantiateUrlUnder(state.rightFrame, animatedUrl);
  const triangle = state.host.nodeHandleByName(animatedImport, "AnimatedTriangle");
  const inventory = JSON.parse(state.host.animationInventoryJson(animatedImport));
  const mixer = state.host.playAnimation(animatedImport, "MoveTriangle", {
    loop_mode: "repeat",
    speed: 1.0,
  });
  state.host.advance(0.5);
  state.host.pauseAnimation(mixer);
  state.host.setTransformEased(
    triangle,
    [0.0, 0.35, 0.0],
    [0.0, 0.0, 0.0, 1.0],
    [1.0, 1.0, 1.0],
    0.5,
    "ease_in_out",
  );
  state.host.setNodeTintEased(triangle, 1.0, 0.15, 0.05, 1.0, 0.5, "linear");
  state.host.advance(0.25);
  state.host.frameNodeWithPreset(triangle, "product_viewer_default");
  state.host.prepare();
  state.host.render();

  return { inventory, instanceRoots, triangle };
}

export function pickCssPixel(state, event) {
  return state.host.pick(event.offsetX, event.offsetY);
}

export function wireSceneHostCamera(canvas, state, requestFrame) {
  let lastPointer = null;
  const request = (action) => {
    if (action !== "none" && action !== "begin_orbit" && action !== "end") {
      requestFrame();
    }
  };
  const buttonName = (button) => {
    if (button === 2) {
      return "secondary";
    }
    if (button === 1) {
      return "auxiliary";
    }
    return "primary";
  };

  canvas.addEventListener("pointerdown", (event) => {
    event.preventDefault();
    lastPointer = { x: event.clientX, y: event.clientY };
    canvas.setPointerCapture?.(event.pointerId);
    request(state.host.cameraPointerDown(event.clientX, event.clientY, buttonName(event.button)));
  });

  canvas.addEventListener("pointermove", (event) => {
    if (!lastPointer) {
      return;
    }
    event.preventDefault();
    const dx = event.clientX - lastPointer.x;
    const dy = event.clientY - lastPointer.y;
    lastPointer = { x: event.clientX, y: event.clientY };
    request(state.host.cameraPointerMove(event.clientX, event.clientY, dx, dy));
  });

  const endPointer = (event) => {
    if (!lastPointer) {
      return;
    }
    event.preventDefault();
    lastPointer = null;
    request(state.host.cameraPointerUp(event.clientX, event.clientY));
  };
  canvas.addEventListener("pointerup", endPointer);
  canvas.addEventListener("pointercancel", endPointer);

  canvas.addEventListener("wheel", (event) => {
    event.preventDefault();
    request(state.host.cameraWheel(event.offsetX, event.offsetY, event.deltaY));
  }, { passive: false });
}