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 режиме:
<!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)¶
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% юзеров никогда не админят. Зачем им это тащить.
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 мы собирали один раз вручную:
- Создали папку
Renderer/. - Скачали portable Node.js Windows binary.
npm install puppeteer three.- Запустили
node -e 'require("puppeteer").launch({headless:"new"})'чтобы Puppeteer скачал свой Chrome. - Запаковали
Renderer/вMiamiGraphicsRenderer_1.0.0.zip. - Залили на R2 + посчитали SHA-256.
Этот zip обновляется редко — мы пересобираем его раз в полгода когда нужно подкатить новый Three.js или Puppeteer. Версия гарантирована через SHA на стороне бутстрапера.