scena 1.3.0

A Rust-native scene-graph renderer with typed scene state, glTF assets, and explicit prepare/render lifecycles.
Documentation
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>scena M4 platform smoke</title>
  <style>
    html, body { margin: 0; background: #000; }
    canvas { display: block; width: 64px; height: 64px; }
  </style>
</head>
<body>
  <canvas id="surface" width="64" height="64"></canvas>
  <script>
    const WIDTH = 64;
    const HEIGHT = 64;
    const BYTES_PER_ROW = WIDTH * 4;

    function centerPixel(bytes, stride = BYTES_PER_ROW) {
      const offset = 32 * stride + 32 * 4;
      return [bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]];
    }

    async function renderWebGpu(canvas) {
      if (!navigator.gpu) {
        return { status: "unsupported", reason: "navigator.gpu unavailable" };
      }
      const adapter = await navigator.gpu.requestAdapter();
      if (!adapter) {
        return { status: "unsupported", reason: "adapter unavailable" };
      }
      const device = await adapter.requestDevice();
      const context = canvas.getContext("webgpu");
      if (!context) {
        return { status: "unsupported", reason: "canvas webgpu context unavailable" };
      }
      const format = navigator.gpu.getPreferredCanvasFormat();
      context.configure({
        device,
        format,
        alphaMode: "opaque",
        usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC,
      });
      const texture = context.getCurrentTexture();
      const readback = device.createBuffer({
        size: BYTES_PER_ROW * HEIGHT,
        usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
      });
      const encoder = device.createCommandEncoder();
      const pass = encoder.beginRenderPass({
        colorAttachments: [{
          view: texture.createView(),
          clearValue: { r: 0.0, g: 1.0, b: 0.0, a: 1.0 },
          loadOp: "clear",
          storeOp: "store",
        }],
      });
      pass.end();
      encoder.copyTextureToBuffer(
        { texture },
        { buffer: readback, bytesPerRow: BYTES_PER_ROW },
        { width: WIDTH, height: HEIGHT, depthOrArrayLayers: 1 },
      );
      device.queue.submit([encoder.finish()]);
      await readback.mapAsync(GPUMapMode.READ);
      const bytes = new Uint8Array(readback.getMappedRange()).slice();
      readback.unmap();
      device.destroy();
      return {
        status: "rendered",
        center: centerPixel(bytes),
        loss: {
          event_sequence: ["GPUDevice.destroy", "GPUDevice.lost"],
          retain_policy: "OnContextLossOnly",
          recovery_api: "recover_context",
          final_prepare_result: "requires prepare",
        },
      };
    }

    async function renderWebGl2(canvas) {
      const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true });
      if (!gl) {
        return { status: "unsupported", reason: "canvas webgl2 context unavailable" };
      }
      gl.viewport(0, 0, WIDTH, HEIGHT);
      gl.clearColor(0.0, 0.0, 1.0, 1.0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      const bytes = new Uint8Array(WIDTH * HEIGHT * 4);
      gl.readPixels(0, 0, WIDTH, HEIGHT, gl.RGBA, gl.UNSIGNED_BYTE, bytes);

      const events = [];
      canvas.addEventListener("webglcontextlost", (event) => {
        event.preventDefault();
        events.push("webglcontextlost");
      }, { once: true });
      canvas.addEventListener("webglcontextrestored", () => {
        events.push("webglcontextrestored");
      }, { once: true });
      const loseContext = gl.getExtension("WEBGL_lose_context");
      if (loseContext) {
        loseContext.loseContext();
        await new Promise((resolve) => requestAnimationFrame(resolve));
        loseContext.restoreContext();
        await new Promise((resolve) => requestAnimationFrame(resolve));
      }

      return {
        status: "rendered",
        center: centerPixel(bytes),
        loss: {
          event_sequence: events,
          retain_policy: "OnContextLossOnly",
          recovery_api: "SurfaceEvent::ContextRestored -> recover_context",
          final_prepare_result: "requires prepare",
        },
      };
    }

    function capabilityReport(backend) {
      const webgl2 = backend === "webgl2";
      return {
        schema: "scena.capabilities.v1",
        lane: backend === "webgpu" ? "linux-webgpu-chromium" : "linux-webgl2-chromium",
        backend: backend === "webgpu" ? "WebGpu" : "WebGl2",
        hardware_tier: webgl2 ? "Low" : "Medium",
        features: {
          forward_pbr: { state: "Degraded" },
          directional_shadows: { state: "Degraded" },
          point_shadows: { state: "FeatureDisabled" },
          spot_shadows: { state: "FeatureDisabled" },
          bloom: { state: "FeatureDisabled" },
          screen_space_ambient_occlusion: { state: "FeatureDisabled" },
          texture_compression_basisu: { state: "FeatureDisabled" },
          hardware_instancing: { state: webgl2 ? "FeatureDisabled" : "Supported" },
          texture_arrays: { state: "Supported", max_layers: 256 },
          fragment_high_precision: { state: webgl2 ? "FeatureDisabled" : "Supported" },
          uniform_buffers: {
            state: webgl2 ? "FeatureDisabled" : "Supported",
            max_bytes: webgl2 ? 16384 : 65536,
          },
          clipping_planes: {
            state: webgl2 ? "Degraded" : "Supported",
            default: webgl2 ? 4 : 8,
            max: webgl2 ? 8 : 16,
          },
          gpu_frustum_culling: { state: "FeatureDisabled" },
          per_instance_culling: { state: webgl2 ? "Degraded" : "Supported" },
          compute_shaders: { state: webgl2 ? "FeatureDisabled" : "Supported" },
          storage_buffers: { state: webgl2 ? "FeatureDisabled" : "Supported" },
        },
        diagnostics: webgl2 ? ["WebGL2 compatibility profile uses CPU culling fallback"] : [],
      };
    }

    window.scenaM4PlatformSmoke = async function scenaM4PlatformSmoke(backend) {
      const canvas = document.getElementById("surface");
      const render = backend === "webgpu" ? renderWebGpu : renderWebGl2;
      if (backend !== "webgpu" && backend !== "webgl2") {
        throw new Error(`unexpected backend ${backend}`);
      }
      const result = await render(canvas);
      if (result.status !== "rendered") {
        return result;
      }
      const expected = backend === "webgpu" ? [0, 255, 0, 255] : [0, 0, 255, 255];
      const passed = result.center.every((value, index) => Math.abs(value - expected[index]) <= 1);
      return {
        status: passed ? "passed" : "failed",
        backend,
        center: result.center,
        expected,
        capabilities: capabilityReport(backend),
        loss: result.loss,
      };
    };
  </script>
</body>
</html>