English · Русский · WebGL Ripper на GitHub
WebGL Ripper не скачивает файлы моделей с сервера. Страница может загрузить модель в любом формате и откуда угодно
или вовсе сгенерировать её в коде — но в итоге любая WebGL-страница отдаёт вершинные буферы, текстуры и шейдеры в
WebGL и вызывает drawArrays / drawElements. Расширение слушает именно это место: записывает один кадр так, как его
видит видеокарта, и превращает каждый вызов отрисовки обратно в меш.
скрипты страницы ──► WebGL API ──► видеокарта
│
перехваты (webglripper.js, внутри страницы)
│ один записанный кадр
▼
вызовы отрисовки ─► очистка ─► превью (viewer.js) ─► GLB / OBJ / STL / USDZ ─► скачивание ─► история, Blender
| Файл | Где выполняется | Задача |
|---|---|---|
webglripper.js |
в странице (мир MAIN), с document_start, во всех фреймах |
перехваты, захват, очистка, экспорт |
viewer.js |
в странице, в закрытом shadow root | превью и подсказка режима выбора |
settings.js, bridge.js |
изолированный мир content script расширения | настройки, горячие клавиши, прогресс, сообщения |
background.js |
service worker (Chrome) / фоновый скрипт (Firefox) | значок, сочетания клавиш браузера, рассылка команд во все фреймы вкладки |
popup.*, options.* |
страницы расширения | кнопки и настройки |
Движок обязан жить в JavaScript-мире самой страницы: только там можно обернуть WebGLRenderingContext для неё.
Manifest V3 позволяет объявить такой скрипт с "world": "MAIN" и "run_at": "document_start", поэтому он
выполняется раньше первого скрипта страницы. Оригинальная версия вставляла тег <script> уже во время работы — он
грузится асинхронно и пропускал каждый контекст, созданный во время загрузки страницы.
Движок и расширение общаются DOM-событиями на document со строками JSON (webglripper:page — команды,
webglripper:ext — статус). Мост читает настройки из chrome.storage.sync и передаёт их с каждой командой, поэтому
изменения применяются сразу, без перезагрузки страницы.
Всё, что движку нужно из окружения страницы (requestAnimationFrame, Blob, CompressionStream, JSON, методы
DOM…), сохраняется при запуске, так что страница, позже подменившая эти функции, не может ни сломать экспорт, ни
подсмотреть его.
Некоторые факты у WebGL потом уже не спросишь, поэтому несколько функций обёрнуты с самого начала:
getContext — чтобы знать каждый WebGL-контекст (в том числе на OffscreenCanvas).texImage2D, texStorage2D, compressedTexImage2D, copyTexImage2D — WebGL не сообщает размер и формат
текстуры, поэтому они запоминаются при загрузке. Оригинал знал размер только у текстур, загруженных из картинки
или canvas, а для остальных (сырые данные, texStorage2D, сжатые) подставлял 4096×4096.getUniformLocation, getExtension — чтобы сопоставить объекты-локации и расширения с их именами и контекстами.framebufferTexture2D — чтобы знать, какие текстуры являются целями рендеринга (и какого фреймбуфера).bufferData, bufferSubData, deleteBuffer. WebGL 1 не умеет читать буферы обратно, поэтому их
содержимое копируется при загрузке. Это именно копии: Unity и другие приложения на Emscripten загружают данные как
срезы одной большой «кучи» и сразу её перезаписывают (оригинал хранил ссылку на такой срез и потом читал мусор).Каждая обёртка — это Proxy вокруг родной функции: страница видит те же name, length и toString()
(«[native code]»), сам вызов уходит прямо в браузер, а работа расширения выполняется после него внутри
try/catch, так что ошибка расширения никогда не попадёт в цикл отрисовки страницы.
Вызовы отрисовки в это время не перехватываются (см. Производительность).
По нажатию клавиши движок ждёт следующий requestAnimationFrame и ставит перехваты ровно на один кадр:
drawArrays, drawElements, drawRangeElements, инстансные варианты (WebGL 2 и
ANGLE_instanced_arrays) и WEBGL_multi_draw;uniform*, uniformMatrix*), чтобы знать матрицы и цвета без медленного запроса
getUniform на каждый вызов;vertexAttribDivisor (поинстансные атрибуты — не вершины), привязки uniform-буферов (bindBufferBase,
bindBufferRange, uniformBlockBinding) для движков, которые держат матрицы в UBO;blitFramebuffer и загрузки буферов WebGL 2 (чтобы выбрасывать устаревшие копии).Кадр записывается от первого вызова отрисовки до конца; если страница ничего не нарисовала (сцены, которые
перерисовываются только при изменениях), движок ждёт кадр с отрисовкой до 15 секунд. Затем перехваты снимаются и
начинается экспорт. Оригинал начинал и заканчивал запись по gl.clear(), поэтому страницы без очистки кадра у него
не заканчивались никогда.
Состояние берётся у самого WebGL, а не из копии, которую ведёт расширение: текущая программа, привязки атрибутов
(VERTEX_ATTRIB_ARRAY_* — с учётом vertex array objects), индексный буфер, фреймбуфер и вьюпорт. Оригинал сам
отслеживал вызовы vertexAttribPointer и не знал о VAO, поэтому на движках с WebGL 2 вроде three.js мог смешивать
чужие буферы.
Какой атрибут что значит. Имена активных атрибутов программы задаёт страница, поэтому позицию, нормаль, UV и цвет
определяет эвристика по именам: сначала точные имена из многих движков (position, a_position, in_POSITION0,
_glesVertex, s_attribute_0…), затем совпадение по словам (aVertexPosition → vertex, position); явно не
геометрия (morph targets, веса скиннинга, смещения инстансов, тангенты) отбрасывается. Незнакомым движкам можно
подсказать имена в настройках.
Вершины читаются только те, что использует вызов: из копий WebGL 1 или в WebGL 2 прямо с видеокарты через
getBufferSubData (каждый буфер один раз за захват). Декодируются все форматы: float, нормализованные и
целочисленные байты/short, half float, упакованные нормали INT_2_10_10_10_REV, любой шаг и смещение. Ленты и веера
превращаются в треугольники с учётом primitive restart в WebGL 2, вырожденные треугольники выбрасываются.
Положение в сцене. Движки кладут матрицу модели в шейдер под самыми разными именами (modelMatrix, u_world,
unity_ObjectToWorld, uMVMatrix…). Движок делит матрицы на модели, модели-вида и вида, читает их (из
перехваченных вызовов, из UBO или в крайнем случае через getUniform) и:
Материалы. Сэмплеры сопоставляются с текстурными блоками и привязанными к ним текстурами и классифицируются по
именам: базовый цвет (map, _MainTex, baseColorTexture, u_texture…), нормали, свечение, шероховатость,
металличность, затенение — и то, что вовсе не текстура материала (карты теней, окружения, LUT, прошлые кадры); это
пропускается. Uniform-цвета (diffuse, u_color, _Color, baseColorFactor…) становятся цветом материала — но не
цвет источника света (directionalLights[0].color); если таких нет, берётся любой другой uniform …Color
(uPartColor), кроме цветов фона, каркаса и подсветки. Uniform-переменные roughness и metalness становятся
множителями материала. Смешивание и отсечение граней определяют прозрачность и doubleSided.
Трансформации текстур. Оптимизированные файлы glTF — результат gltfpack и meshoptimizer, которые отдают ИИ-генераторы
моделей вроде Meshy, — хранят текстурные координаты 12- или 16-битными целыми и растягивают их обратно через
KHR_texture_transform. «Сырые» координаты такой модели покрывают только угол текстуры (1/16 при 12 битах), поэтому
трансформация текстуры базового цвета находится по имени сэмплера и применяется к UV: mapTransform / uvTransform
(three.js), _MainTex_ST (Unity), diffuseMatrix (Babylon.js), texture_diffuseMapTransform0/1 (PlayCanvas).
Персонаж, анимированный на видеокарте, загружается один раз в исходной позе (часто T-поза), а каждый кадр вершинный шейдер сдвигает его вершины матрицами костей или весами morph targets. Поэтому в буферах лежит T-поза — её и сохраняют другие риперы.
На страницах с WebGL 2 WebGL Ripper спрашивает у видеокарты, куда ушли вершины. Если программа похожа на анимированную (атрибуты или uniform-переменные с именами вроде bone, joint, skin, morph), движок:
gl_Position, который записывается через transform feedback;RASTERIZER_DISCARD (на экран ничего не попадает) в свой
буфер и читает его обратно;Результат расставляется по сцене, как любой другой меш; нормали затем рассчитываются заново, потому что нормали из шейдера не захватываются. Состояние GL страницы (программа, transform feedback, привязки буферов, rasterizer discard) сразу восстанавливается. В WebGL 1 нет transform feedback, поэтому там персонажи остаются в исходной позе. Отключается настройкой Персонажи в текущей позе.
В кадре много того, что моделью не является. По порядку:
Текстуры читаются с видеокарты в настоящем размере, полосами примерно по 4 МБ:
readPixels;Состояние GL страницы — привязки, активная текстура, параметры упаковки пикселей, вьюпорт, собственные параметры выборки текстуры — сохраняется и восстанавливается вокруг каждого чтения, а тесты проверяют, что состояние страницы до и после захвата совпадает.
Каждая текстура кодируется в PNG один раз и используется и превью, и GLB, и OBJ. Кодировщик потоковый: полоса
читается, фильтруется и сжимается, пока читается следующая, поэтому 4K-текстура никогда не лежит в памяти целиком.
Непрозрачные текстуры сохраняются в RGB, остальные в RGBA с точным цветом под прозрачными пикселями
(canvas.toDataURL, который использовал оригинал, домножает цвет на альфу и уничтожает его). Все строки используют
PNG-фильтр «Up»: на реальной 4K-текстуре он сжимает не хуже Paeth или лучше, а deflate справляется примерно на треть
быстрее.
Если превью включено, до его открытия читаются только текстуры базового цвета — именно их оно показывает. Остальные карты читаются после нажатия Download и только для выбранных мешей.
viewer.js рисует захваченные меши поверх страницы: собственный canvas WebGL 2 в закрытом shadow root, стили через
constructed stylesheet, разметка без innerHTML, поэтому CSS, скрипты, Trusted Types и CSP страницы на него не влияют,
а собственный WebGL-контекст превью движок игнорирует.
captureStream() и MediaRecorder (WebM VP9 или VP8, 8 Мбит/с); исключённые меши на время
записи скрываются.Режим выбора отвечает на вопрос «какой вызов отрисовки нарисовал этот пиксель?»:
Экспортируется только выбранный меш (или в превью он выбран заранее).
GLB (бинарный glTF 2.0) — по узлу и мешу на каждый захваченный меш с POSITION, NORMAL, TEXCOORD_0
(перевёрнутыми под соглашение glTF) и COLOR_0, 16- или 32-битными индексами, PBR-материалами (базовый цвет,
нормали, свечение, затенение и металл/шероховатость, если страница берёт их из одной текстуры, как того ждёт glTF) и
встроенными PNG. Он собирается из частей Blob, поэтому геометрия и текстуры не копируются в один огромный буфер.
Камера страницы попадает в GLB узлом Page camera: её положение — обратная матрица вида, общей для вызовов отрисовки самого крупного меша, объектив — из их матрицы проекции (угол обзора, соотношение сторон, ближняя и дальняя плоскости или размер ортографической камеры); при центрировании экспорта она сдвигается вместе с моделью. Импорт glTF в Blender создаёт из неё объект-камеру, и Numpad 0 показывает тот же вид, что и страница.
glTF ждёт шероховатость (зелёный канал) и металличность (синий) в одной текстуре. Если страница берёт их из двух
текстур (three.js читает roughnessMap.g и metalnessMap.b), при экспорте они объединяются в одну, по размеру большей,
и ни одна не теряется.
Лёгкий GLB использует KHR_mesh_quantization: позиции становятся 16-битными целыми вокруг центра каждого меша
(перенос и равномерный масштаб узла возвращают их на место), нормали — 8-битными, UV — 16-битными, если не выходят за
0..1, цвета — 8-битными; непрозрачные текстуры перекодируются в JPEG (качество 0,9), если так меньше. Геометрия
занимает примерно вдвое меньше места; модель с фототекстурой 768×768 уменьшилась с 760 до 136 КБ.
STL — бинарный: по грани на каждый треугольник выбранных мешей, расставленных по сцене, с нормалью грани; ось Y вверх превращается в Z вверх, чтобы модель стояла на столе слайсера.
USDZ — текстовый слой USD (.usda) с мешем на каждый выбранный меш (точки, нормали, UV, индексы граней) и
материалом UsdPreviewSurface с текстурами базового цвета, нормалей и свечения, упакованный вместе с PNG в несжатый
zip, где каждый файл выровнен по 64 байтам, — так требует AR Quick Look на iPhone и iPad.
OBJ индексирует позиции, UV и нормали раздельно: каждое уникальное значение пишется один раз, и меши в Blender не
распадаются по швам UV и острым граням. Текст генерируется кусками по 1 МБ. Материалы — в одном MTL-файле с Kd,
Ke, d и текстурами.
ZIP: файлы сжимаются прямо при записи (CompressionStream('deflate-raw'), CRC-32 считается по 16 байт за шаг),
PNG кладутся как есть, а мелкие файлы собираются в блоки по 8 МБ, прежде чем стать Blob браузера (который может уйти
на диск), — вместо отдельного Blob на каждый файл.
Скачивание — это blob:-ссылка, по которой кликает скрытый элемент. Во время долгих шагов экспорт каждые ~30 мс
делает паузу через message channel, чтобы страница продолжала рисоваться; таймеры для этого не годятся — браузеры
замедляют их во фоновых вкладках.
Когда рип сохранён, страница отправляет фоновому скрипту короткую сводку: имя файла, числа, что было отброшено, адрес и
заголовок страницы и маленькие миниатюры, сделанные для окна расширения. Фоновый скрипт хранит последние 50 в
storage.local (для приватных окон — никогда) для страницы истории. Чтобы снова найти файл, она ищет его по имени в
списке загрузок браузера (имя с суффиксом (1) тоже подходит) и вызывает downloads.show или downloads.open;
разрешение downloads необязательное и запрашивается только при первом нажатии Показать в папке.
Аддон для Blender (blender/webglripper_blender.py) каждые 1,5 секунды по таймеру смотрит файлы webglripper_* в
отслеживаемой папке. Файлы, которые были там до начала слежения, пропускаются; новый импортируется, когда рядом нет
.crdownload / .part и его размер не изменился с прошлого раза. GLB идёт через импорт glTF в Blender, STL и USDZ —
через их импорт, zip распаковывается рядом с собой, и импортируются model.glb, scene.obj или файлы mesh_*.obj.
Всё импортированное попадает в новую коллекцию с именем файла, выделяется и показывается в виде; Page camera
становится камерой сцены, если своей камеры нет.
Интерфейс — на английском и русском. В i18n.js лежат переводы, ключ — английский текст; формы множественного числа
выбираются через Intl.PluralRules (одна / несколько / много для русского). Страницы расширения переводят свой
статичный текст до показа; движок на странице и превью получают таблицу от content script с каждым захватом, поэтому
статусы в окне расширения, превью и подсказка режима выбора говорят на том же языке. Автоматически — по языку
интерфейса браузера. Тест проверяет, что у каждого текста из кода и страниц есть перевод.
| Что делается | |
|---|---|
| Обычный просмотр | Только постоянные перехваты из списка выше; на вызовы отрисовки — ноль затрат. Сцена WebGL 2 с 3000 вызовов за кадр рисуется так же быстро, как без расширения. |
| Во время записи кадра | Разбор программ кешируется, значения uniform берутся из перехваченных вызовов, буферы читаются с видеокарты один раз за захват, divisor и привязки UBO берутся из перехватов вместо запросов getParameter. |
| Экспорт | Всё идёт кусками по 1–8 МБ; текстуры читаются полосами по 4 МБ; сварка вершин использует хеш-таблицу с правильным перемешиванием (круглые координаты — целые сетки, воксели, CAD — не сталкиваются); PNG — фильтр Up; контрольные суммы ZIP — slicing-by-16; страница получает кадр каждые ~30 мс. |
| Превью | До открытия — только текстуры базового цвета, каждая декодируется один раз и сразу в размере превью; выбор мешей — на видеокарте. |
Цифры — в сравнении с оригиналом.
storage (настройки и история — обе хранятся в браузере) и доступ к страницам (чтобы запускать в
них движок). downloads — необязательное: история просит его при первом Показать в папке.OffscreenCanvas, переданный в worker) недоступен скриптам страницы.WEBGL_multi_draw нет матриц отдельных объектов.rip-info.json показывает, какие имена использует страница. То же касается распознавания анимированных программ
для поз.