Перейти к содержанию

Headless рендер на серверной стороне

Когда админ заливает гана-пак, мы конвертируем .ydr/.ydd.glb (см. предыдущий раздел). Но для карточек в каталоге нужны статичные PNG-превью, не интерактивный 3D в каждой ячейке грида (это убьёт GPU).

Делаем рендер в headless Chrome через Puppeteer + локальный Node-сервер с Three.js. Результат — PNG-файл сохраняем в R2.

Зачем headless

Альтернативы которые отверг:

Подход Минус
Server-side Three.js в Node без браузера Three.js требует WebGL, в Node его нет (есть node-canvas но без шейдеров)
Скриншот UI через C# WebView2 → PNG Запускать UI в admin pipeline сложно, требует видимое окно или off-screen WebView2 (нестабильно)
Просто использовать первое preview-изображение от автора мода Авторы не всегда дают, не унифицировано — где-то 4К где-то 200×200
Online сервис типа model-viewer.dev Загружать наш .glb на внешний сервис — приватные данные + зависимость

Headless Chrome — единственный надёжный путь. У нас есть полноценный WebGL контекст без видимого окна.

Реализация

Renderer/ — отдельная папка рядом с лаунчером. Контейнирует:

Renderer/
├── node.exe                ← portable Node.js, ~30 МБ
├── package.json
├── package-lock.json
├── render.html             ← страница со скриптом
├── render.js               ← bootstrap скрипт что запускается через node
└── node_modules/
    ├── puppeteer-core/
    ├── three/
    └── puppeteer/.local-chromium/  ← headless Chrome

При admin upload'е C# code запускает:

var psi = new ProcessStartInfo
{
    FileName = Path.Combine(rendererDir, "node.exe"),
    Arguments = "render.js " + JsonEscape(args),
    UseShellExecute = false,
    RedirectStandardOutput = true,
    WorkingDirectory = rendererDir,
};
var p = Process.Start(psi);
// читаем stdout: PNG base64 string

render.html

Минимальная страница которая создаёт canvas и грузит GLB. Не отображается — Chrome работает в headless режиме:

HunterGraphics.Core/Renderer/render.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>HNTGRAPH Renderer</title>
<style>
  html, body { margin: 0; padding: 0; background: transparent; }
  canvas { display: block; }
</style>
<script type="importmap">
{
  "imports": {
    "three": "./node_modules/three/build/three.module.js",
    "three/addons/": "./node_modules/three/examples/jsm/"
  }
}
</script>
</head>
<body>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

// Принимает base64 GLB + размеры, возвращает dataURL PNG.
window.renderGlb = async function(glbBase64, width, height) {
    const binStr = atob(glbBase64);
    const buf = new ArrayBuffer(binStr.length);
    const view = new Uint8Array(buf);
    for (let i = 0; i < binStr.length; i++) view[i] = binStr.charCodeAt(i);

    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = height;
    document.body.appendChild(canvas);

    const renderer = new THREE.WebGLRenderer({
        canvas,
        antialias: true,
        alpha: true,
        preserveDrawingBuffer: true   // важно — без этого toDataURL вернёт пустой PNG
    });
    renderer.setSize(width, height, false);
    renderer.setPixelRatio(1);
    renderer.setClearColor(0x000000, 0);
    renderer.outputColorSpace = THREE.SRGBColorSpace;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.0;

    // ... настройка сцены, света, камеры ...
    // ... loader.parse(buf, '', resolve) ...
    // ... auto-fit камеры по bounding box ...
    renderer.render(scene, camera);

    return canvas.toDataURL('image/png');
};
</script>
</body>
</html>

preserveDrawingBuffer: true — критичная настройка. Без неё toDataURL() после render() вернёт пустой или последнего кадра. WebGL по умолчанию сразу после present очищает back buffer.

render.js (Node side)

Renderer/render.js (упрощённо)
const puppeteer = require('puppeteer');
const fs = require('fs');

(async () => {
    const args = JSON.parse(process.argv[2]);   // { glbPath, outPath, width, height }
    const glbB64 = fs.readFileSync(args.glbPath).toString('base64');

    const browser = await puppeteer.launch({
        headless: 'new',
        args: [
            '--no-sandbox',
            '--disable-setuid-sandbox',
            '--use-gl=swiftshader',   // software WebGL для безголовой машины без GPU
        ],
    });
    const page = await browser.newPage();
    await page.goto('file://' + __dirname + '/render.html');

    const dataUrl = await page.evaluate(
        async (b64, w, h) => await window.renderGlb(b64, w, h),
        glbB64, args.width, args.height
    );

    // dataUrl это "data:image/png;base64,iVBOR..."
    const base64 = dataUrl.replace(/^data:image\/png;base64,/, '');
    fs.writeFileSync(args.outPath, Buffer.from(base64, 'base64'));

    await browser.close();
    console.log('OK');
})();

--use-gl=swiftshader — SwiftShader это software WebGL implementation от Google. Работает без видеокарты (для виртуальных серверов или CI), и не зависит от драйверов конкретной GPU. Достаточен для рендера ганов и брони — текстуры умеет, шейдеры умеет, антиалиасинг да.

Чуть медленнее чем GPU-render (1-3 секунды на ствол вместо 100 мс), но в фоне на админ-stage'е это норма.

Bootstrapper

Renderer не приходит с инсталлером — это on-demand download. Зачем — он 160 МБ (Chrome бинарь + node_modules), а 99% юзеров никогда не админят. Зачем им это тащить.

HunterGraphics.Shell/Services/RendererBootstrapper.cs
public sealed class RendererBootstrapper
{
    private const string RendererZipUrl = "https://cdn.miamigraphicsstorage.uk/releases/MiamiGraphicsRenderer_1.0.0.zip";
    private const string RendererSha256 = "B18B0318CE697EBD3D4A3922C1A7C73FBEB9AE3AE4C7DFB7B3CAE9580AC492BD";

    public async Task<EnsureResult> EnsureInstalledAsync(...)
    {
        if (IsAlreadyInstalled())
            return new EnsureResult(true, alreadyInstalled: true, ...);

        // 1. Качаем zip с CDN через FragmentingHttpHandler
        // 2. Verify SHA-256
        // 3. Распаковываем рядом с лаунчером
        // 4. Возвращаем success
    }
}

UI триггерит install:

  • Когда админ заходит в Admin → Guns (там нужен Renderer для preview generation);
  • При первом upload'е гана-пака если ещё не установлен.

Юзеры обычные никогда не qualifiedly не докачивают эти 160 МБ.

ZIP содержит чужой бинарь

MiamiGraphicsRenderer_1.0.0.zip мы собирали один раз вручную:

  1. Создали папку Renderer/.
  2. Скачали portable Node.js Windows binary.
  3. npm install puppeteer three.
  4. Запустили node -e 'require("puppeteer").launch({headless:"new"})' чтобы Puppeteer скачал свой Chrome.
  5. Запаковали Renderer/ в MiamiGraphicsRenderer_1.0.0.zip.
  6. Залили на R2 + посчитали SHA-256.

Этот zip обновляется редко — мы пересобираем его раз в полгода когда нужно подкатить новый Three.js или Puppeteer. Версия гарантирована через SHA на стороне бутстрапера.

Дальше: история шейдеров →