- PVSM.RU - https://www.pvsm.ru -
WebGPU — это новый стандарт для доступа к возможностям видеокарт, который я уже несколько лет хочу использовать в своем проекте. Два года, даже с включенными флагами, у меня не получалось с моей встроенной видеокартой это сделать. В отличие от WebGL, WebGPU создавался с нуля под архитектуры современных GPU, предоставляя разработчикам низкоуровневый контроль над вычислениями, поддержку compute-шейдеров и высокую производительность в браузерах.
Но сегодня эта технология выходит из экспериментального тестирования. На момент написания статьи webGPU уже доступна в Chrome, Edge и Firefox (под флагом). Все тестовые примеры начали запускаться у меня на компьютере и я решил глубже разобраться с этой технологией.
Прежде чем начать писать что то я посмотрел в интернете, какие библиотеки можно уже сейчас использовать для написания приложений. Ниже я сделал сравнительную таблицу с известными библиотеками, которые сейчас часто используются.
|
Характеристика |
Orillusion |
Three.js |
Babylon.js |
|---|---|---|---|
|
Базовая технология |
WebGPU (нативная) |
WebGL/WebGPU |
WebGL/WebGPU |
|
Архитектура |
ECS |
ООП |
ООП |
|
Поддержка WebGPU |
✅ Полная |
⚠️ Экспериментальная |
✅ Стабильная |
|
Compute Shaders |
✅ Нативная |
⚠️ Ограниченная |
✅ Хорошая |
|
Производительность |
Очень высокая |
Высокая |
Высокая |
|
Порог входа |
Высокий |
Низкий |
Средний |
|
Документация |
Развивающаяся |
Отличная |
Хорошая |
|
Сообщество |
Маленькое |
Огромное |
Большое |
Несмотря на плюсы, от использования Three.js и Babylon.js я все таки остановился на Orillusion, потому что это современный движок, полностью построенный на WebGPU. Он создавался с нуля с учётом новых возможностей API.
Изучив документацию Orillusion [1], я заметил, что мне не хватает примеров создания кастомных шейдеров. Я нашел описание стандартных материалов, руководства по использованию готовых компонентов, но информации о том, как написать свой вершинный/фрагментный шейдер с нуля и подключить его к материалу, в документации сейчас нет. То, что нашел в разделе про Unlit Material [2], даёт общее представление о том как подключать, но нет полного примера. Непонятно, с каким синтаксисом создавать шейдеры, как правильно связывать атрибуты геометрии и как интегрировать compute-шейдеры.
Я решил это исправить. Моя цель была — научиться создавать объекты с кастомными шейдерами с нуля и разобраться в процессе изнутри. План для себя составил следующий:
Понять, как регистрировать кастомные шейдеры и связывать их с геометрией
Научиться работать с атрибутами и uniform-буферами
Интегрировать compute-шейдеры для GPU-вычислений
Разобраться с механикой инстансинга — как отрисовать множество объектов с разными параметрами (позиции, скорости вращения) за один draw call
Моей целью стало пройти весь путь создания сложной сцены с нуля, чтобы в будущем, на основе этого примера использовать эту последовательность в своем проекте.
Маленькое сообщество — найти готовые решения сложнее, чем для Three.js.
Развивающаяся документация — некоторые возможности ещё не полностью документированы. Примеров кастомных шейдеров практически нет.
Высокий порог входа — требует понимания WebGPU и современных графических концепций.
Но несмотря на это, нативная поддержка Compute Shaders, архитектура ECS и отсутствие легаси-кода WebGL перевесили минусы.
Я создал интерактивную 3D-сцену с пятью тессерактами (4D-гиперкубами), которые вращаются в четырёхмерном пространстве с использованием:
WebGPU (через библиотеку Orillusion)
Кастомных вершинных/фрагментных шейдеров на WGSL
Compute-шейдеров для GPU-вычислений
Для начала я создал обычный куб с кастомным шейдером. И столкнулся с несколькими ошибками.
Шейдер ожидал атрибуты, которые геометрия не предоставляла. В Orillusion атрибуты нужно явно регистрировать через setAttribute().
Color target has no corresponding fragment stage output
Оказалось, что в Orillusion фрагментный шейдер обязан выводить данные во все 4 render target слота:
struct FragmentOutput {
@location(0) color: vec4<f32>, // Основной цвет
@location(1) dummy1: vec4<f32>, // Пустышка
@location(2) dummy2: vec4<f32>, // Пустышка
@location(3) dummy3: vec4<f32>, // Пустышка
}
@fragment
fn main(@location(0) v_color: vec4<f32>) -> FragmentOutput {
var output: FragmentOutput;
output.color = v_color;
output.dummy1 = vec4<f32>(0.0);
output.dummy2 = vec4<f32>(0.0);
output.dummy3 = vec4<f32>(0.0);
return output;
}
Тессеракт имеет 16 вершин в 4D-пространстве (координаты x, y, z, w) и 32 ребра:
const vertices4D = [
[-1,-1,-1,-1], [ 1,-1,-1,-1], [ 1,-1, 1,-1], [-1,-1, 1,-1],
[-1, 1,-1,-1], [ 1, 1,-1,-1], [ 1, 1, 1,-1], [-1, 1, 1,-1],
[-1,-1,-1, 1], [ 1,-1,-1, 1], [ 1,-1, 1, 1], [-1,-1, 1, 1],
[-1, 1,-1, 1], [ 1, 1,-1, 1], [ 1, 1, 1, 1], [-1, 1, 1, 1]
];
Для анимации я использовал метод onUpdate() из ComponentBase:
class TesseractComponent extends ComponentBase {
onUpdate() {
// Вызывается каждый кадр движком
this.updateRotation();
}
}
В 4D объект вращается в плоскости (например, XW). Формула:
x' = x·cos(α) — w·sin(α)
w' = x·sin(α) + w·cos(α)
В шейдере применяются вращения в плоскостях XW, YW, ZW:
fn rotate4D(p: vec4<f32>, data: TransformData) -> vec4<f32> {
var pos = p;
let cosXW = cos(data.angleXW);
let sinXW = sin(data.angleXW);
let x1 = pos.x * cosXW - pos.w * sinXW;
let w1 = pos.x * sinXW + pos.w * cosXW;
pos.x = x1;
pos.w = w1;
// Аналогично для YW и ZW...
return pos;
}
fn project4D(pos4D: vec4<f32>, data: TransformData) -> vec3<f32> {
let perspective = data.perspectiveDistance /
(data.perspectiveDistance + pos4D.w * 0.4);
return vec3<f32>(
pos4D.x * perspective,
pos4D.y * perspective,
pos4D.z * perspective
);
}
Углы вращения меняются каждый кадр. Вместо CPU-вычислений я использую GPU что бы данные никогда не покидали видеопамять.
Структура в StorageGPUBuffer должна быть кратной 16 байтам:
struct TransformData {
time: f32,
angleXW: f32,
angleYW: f32,
angleZW: f32,
angleXY: f32,
angleXZ: f32,
angleYZ: f32,
perspectiveDistance: f32,
scale: f32,
_pad1: f32, // паддинги до 48 байт
_pad2: f32,
_pad3: f32,
}
@group(0) @binding(0) var<storage, read_write> transform: TransformData;
@group(0) @binding(1) var<uniform> deltaTime: f32;
@compute @workgroup_size(1, 1, 1)
fn CsMain(@builtin(global_invocation_id) id: vec3<u32>) {
var data = transform;
data.time = data.time + deltaTime;
data.angleXW = data.time * 0.8;
data.angleYW = data.time * 0.6;
data.angleZW = data.time * 0.4;
data.perspectiveDistance = 3.5 + sin(data.time * 0.8) * 1.5;
transform = data;
}
onUpdate() {
const dt = 0.016 * this.speedMultiplier;
this.deltaBuffer.setFloat(0, dt);
this.deltaBuffer.apply();
const commandEncoder = webGPUContext.device.createCommandEncoder();
const computePass = commandEncoder.beginComputePass();
this.computeShader.compute(computePass);
computePass.end();
webGPUContext.device.queue.submit([commandEncoder.finish()]);
}
Одна из ключевых задач для меня стала отрисовать 5 тессерактов на окружности, каждый с уникальной позицией и скоростью вращения, но с минимальными затратами.
Для этой задачи в библиотеке используется инстансинг. Он работает через механизм instance_index в шейдере:
@builtin(instance_index) instanceIndex: u32
Движок автоматически прокидывает матрицы трансформации для каждого инстанса через models.matrix[instanceIndex]. Мне оставалось только:
Создать 5 объектов Object3D с разными позициями на окружности
Добавить каждому компонент TesseractComponent с уникальной speedMultiplier
В шейдере умножить позицию вершины на соответствующую матрицу:
let worldPos = models.matrix[instanceIndex] * vec4<f32>(scaledPos, 1.0);
output.position = globalUniform.projMat * globalUniform.viewMat * worldPos;
В результате все 5 тессерактов отрисовываются за один draw call, что даёт огромный выигрыш в производительности и что позволяет в браузере оперировать огромным количеством объектов.
Финальная сцена содержит 5 тессерактов на окружности радиусом 12 единиц. Каждый вращается с уникальной скоростью (0.5x, 1.0x, 1.5x, 2.0x, 1.2x).
WebGPU открывает новые возможности. Compute Shaders разгружают CPU и позволяют делать сложные эффекты.
Orillusion — хороший выбор для энтузиастов. Сообщество небольшое, документация неполная, но архитектура ECS и нативная поддержка WebGPU стоят того. При желании разобраться не сложно.
Кастомные шейдеры в orillusion — это страшно только вначале. Главное — понять систему атрибутов, выравнивание данных и требования к фрагментным выходам.
Инстансинг работает “из коробки”. Orillusion автоматически предоставляет models.matrix[instance_index], достаточно правильно передать атрибуты и использовать instanceIndex в шейдере.
Попробуйте сами: код требует браузер с поддержкой WebGPU (Chrome 113+, Edge 113+, Firefox Nightly). Управление: мышь
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Gaia Star Map - 4D Тессеракт | WebGPU</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', 'Monaco', 'Menlo', 'Consolas', monospace;
background: #000;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
#canvas {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: block;
outline: none;
z-index: 1;
pointer-events: auto;
}
@keyframes pulse {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="importmap">
{
"imports": {
"@orillusion/core": "https://unpkg.com/@orillusion/core@0.8.5-dev.9/dist/orillusion.es.js"
}
}
</script>
<script type="module">
import {
Engine3D,
Scene3D,
Camera3D,
Object3D,
Vector3,
View3D,
AtmosphericComponent,
DirectLight,
HoverCameraController,
GeometryBase,
BoundingBox,
ShaderLib,
RenderShaderPass,
Shader,
Material,
PassType,
BlendMode,
GPUCompareFunction,
GPUCullMode,
UniformGPUBuffer,
StorageGPUBuffer,
ComponentBase,
ComputeShader,
MeshRenderer,
webGPUContext
} from '@orillusion/core';
const vertices4D = [
[-1, -1, -1, -1], [ 1, -1, -1, -1], [ 1, -1, 1, -1], [-1, -1, 1, -1],
[-1, 1, -1, -1], [ 1, 1, -1, -1], [ 1, 1, 1, -1], [-1, 1, 1, -1],
[-1, -1, -1, 1], [ 1, -1, -1, 1], [ 1, -1, 1, 1], [-1, -1, 1, 1],
[-1, 1, -1, 1], [ 1, 1, -1, 1], [ 1, 1, 1, 1], [-1, 1, 1, 1]
];
const edges = [
[0,1], [1,2], [2,3], [3,0], [4,5], [5,6], [6,7], [7,4],
[0,4], [1,5], [2,6], [3,7], [8,9], [9,10], [10,11], [11,8],
[12,13], [13,14], [14,15], [15,12], [8,12], [9,13], [10,14], [11,15],
[0,8], [1,9], [2,10], [3,11], [4,12], [5,13], [6,14], [7,15]
];
class TesseractGeometry extends GeometryBase {
constructor() {
super();
const positions = [];
const wCoords = [];
const colors = [];
const indices = [];
let idx = 0;
for (let i = 0; i < edges.length; i++) {
const v1 = vertices4D[edges[i][0]];
const v2 = vertices4D[edges[i][1]];
positions.push(v1[0], v1[1], v1[2]);
positions.push(v2[0], v2[1], v2[2]);
wCoords.push(v1[3], v2[3]);
const hue = i / edges.length;
colors.push(0.5 + 0.5 * Math.sin(hue * Math.PI * 2), 0.3, 0.7, 1.0);
colors.push(0.3, 0.5 + 0.5 * Math.sin(hue * Math.PI * 2), 0.7, 1.0);
indices.push(idx, idx + 1);
idx += 2;
}
this.setAttribute('a_position', new Float32Array(positions));
this.setAttribute('a_wCoord', new Float32Array(wCoords));
this.setAttribute('a_color', new Float32Array(colors));
this.setIndices(new Uint16Array(indices));
const subGeo = this.addSubGeometry({
indexStart: 0,
indexCount: indices.length,
vertexStart: 0,
vertexCount: idx,
firstStart: 0,
index: 0,
topology: 1
});
if (subGeo) {
subGeo.lodLevels = [{
indexStart: 0,
indexCount: indices.length,
vertexStart: 0,
vertexCount: idx,
firstStart: 0,
index: 0,
topology: 1
}];
}
const bounds = new BoundingBox();
bounds.min.set(-2.5, -2.5, -2.5);
bounds.max.set(2.5, 2.5, 2.5);
this.bounds = bounds;
this.name = 'TesseractGeometry';
}
}
const VS_NAME = 'tesseract_final_vs';
const FS_NAME = 'tesseract_final_fs';
const COMPUTE_NAME = 'tesseract_final_compute';
const vsCode = `
#include "GlobalUniform"
#include "WorldMatrixUniform"
struct TransformData {
time: f32,
angleXW: f32,
angleYW: f32,
angleZW: f32,
angleXY: f32,
angleXZ: f32,
angleYZ: f32,
perspectiveDistance: f32,
scale: f32,
_pad1: f32,
_pad2: f32,
_pad3: f32,
}
@group(1) @binding(0) var<storage, read> transformData: TransformData;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) v_color: vec4<f32>,
}
fn rotate4D(p: vec4<f32>, data: TransformData) -> vec4<f32> {
var pos = p;
let cosXW = cos(data.angleXW);
let sinXW = sin(data.angleXW);
let x1 = pos.x * cosXW - pos.w * sinXW;
let w1 = pos.x * sinXW + pos.w * cosXW;
pos.x = x1;
pos.w = w1;
let cosYW = cos(data.angleYW);
let sinYW = sin(data.angleYW);
let y1 = pos.y * cosYW - pos.w * sinYW;
let w2 = pos.y * sinYW + pos.w * cosYW;
pos.y = y1;
pos.w = w2;
let cosZW = cos(data.angleZW);
let sinZW = sin(data.angleZW);
let z1 = pos.z * cosZW - pos.w * sinZW;
let w3 = pos.z * sinZW + pos.w * cosZW;
pos.z = z1;
pos.w = w3;
return pos;
}
fn project4D(pos4D: vec4<f32>, data: TransformData) -> vec3<f32> {
let perspective = data.perspectiveDistance / (data.perspectiveDistance + pos4D.w * 0.4);
return vec3<f32>(pos4D.x * perspective, pos4D.y * perspective, pos4D.z * perspective);
}
@vertex
fn main(
@builtin(instance_index) instanceIndex: u32,
@location(0) a_position: vec3<f32>,
@location(1) a_wCoord: f32,
@location(2) a_color: vec4<f32>
) -> VertexOutput {
var output: VertexOutput;
let pos4D = vec4<f32>(a_position.x, a_position.y, a_position.z, a_wCoord);
let rotated4D = rotate4D(pos4D, transformData);
let pos3D = project4D(rotated4D, transformData);
let scaledPos = pos3D * transformData.scale;
let worldPos = models.matrix[instanceIndex] * vec4<f32>(scaledPos, 1.0);
output.position = globalUniform.projMat * globalUniform.viewMat * worldPos;
let brightness = 0.6 + 0.4 * sin(transformData.time * 3.0 + rotated4D.w * 5.0);
output.v_color = vec4<f32>(
a_color.r * brightness,
a_color.g * brightness,
a_color.b * brightness,
1.0
);
return output;
}
`;
const fsCode = `
struct FragmentOutput {
@location(0) color: vec4<f32>,
@location(1) dummy1: vec4<f32>,
@location(2) dummy2: vec4<f32>,
@location(3) dummy3: vec4<f32>,
}
@fragment
fn main(
@location(0) v_color: vec4<f32>
) -> FragmentOutput {
var output: FragmentOutput;
output.color = v_color;
output.dummy1 = vec4<f32>(0.0);
output.dummy2 = vec4<f32>(0.0);
output.dummy3 = vec4<f32>(0.0);
return output;
}
`;
const computeCode = `
struct TransformData {
time: f32,
angleXW: f32,
angleYW: f32,
angleZW: f32,
angleXY: f32,
angleXZ: f32,
angleYZ: f32,
perspectiveDistance: f32,
scale: f32,
_pad1: f32,
_pad2: f32,
_pad3: f32,
}
@group(0) @binding(0) var<storage, read_write> transform: TransformData;
@group(0) @binding(1) var<uniform> deltaTime: f32;
@compute @workgroup_size(1, 1, 1)
fn CsMain(@builtin(global_invocation_id) id: vec3<u32>) {
var data = transform;
data.time = data.time + deltaTime;
data.angleXW = data.time * 0.8;
data.angleYW = data.time * 0.6;
data.angleZW = data.time * 0.4;
data.angleXY = data.time * 0.5;
data.angleXZ = data.time * 0.7;
data.angleYZ = data.time * 0.3;
data.perspectiveDistance = 3.5 + sin(data.time * 0.8) * 1.5;
data.scale = 1.0;
transform = data;
}
`;
// ============================================================================
// КОМПОНЕНТ ТЕССЕРАКТА
// ============================================================================
class TesseractComponent extends ComponentBase {
constructor() {
super();
this.geometry = null;
this.transformBuffer = null;
this.deltaBuffer = null;
this.computeShader = null;
this.speedMultiplier = 1.0;
}
start() {
this.geometry = new TesseractGeometry();
this.transformBuffer = new StorageGPUBuffer(12 * 4);
const initialData = new Float32Array(12);
initialData[0] = 0;
initialData[1] = 0;
initialData[2] = 0;
initialData[3] = 0;
initialData[4] = 0;
initialData[5] = 0;
initialData[6] = 0;
initialData[7] = 4.0;
initialData[8] = 1.0;
initialData[9] = 0;
initialData[10] = 0;
initialData[11] = 0;
this.transformBuffer.outFloat32Array = initialData;
this.transformBuffer.apply();
this.deltaBuffer = new UniformGPUBuffer(4);
const renderPass = new RenderShaderPass(VS_NAME, FS_NAME);
renderPass.passType = PassType.COLOR;
renderPass.blendMode = BlendMode.NORMAL;
renderPass.depthWriteEnabled = true;
renderPass.depthCompare = GPUCompareFunction.less;
renderPass.cullMode = GPUCullMode.none;
renderPass.topology = 'line-list';
renderPass.setStorageBuffer('transformData', this.transformBuffer);
const shader = new Shader();
shader.addRenderPass(renderPass);
const material = new Material();
material.shader = shader;
const renderer = this.object3D.addComponent(MeshRenderer);
renderer.geometry = this.geometry;
renderer.material = material;
this.computeShader = new ComputeShader(computeCode);
this.computeShader.setStorageBuffer('transform', this.transformBuffer);
this.computeShader.setUniformBuffer('deltaTime', this.deltaBuffer);
this.computeShader.workerSizeX = 1;
this.computeShader.workerSizeY = 1;
this.computeShader.workerSizeZ = 1;
}
onUpdate() {
if (!this.computeShader || !this.deltaBuffer) return;
const dt = 0.016 * this.speedMultiplier;
this.deltaBuffer.setFloat(0, dt);
this.deltaBuffer.apply();
const commandEncoder = webGPUContext.device.createCommandEncoder();
const computePass = commandEncoder.beginComputePass();
this.computeShader.compute(computePass);
computePass.end();
webGPUContext.device.queue.submit([commandEncoder.finish()]);
}
destroy(force) {
if (this.transformBuffer) this.transformBuffer.destroy();
if (this.deltaBuffer) this.deltaBuffer.destroy();
if (this.computeShader) this.computeShader.destroy();
super.destroy(force);
}
}
function getCirclePosition(radius, angleDeg, yOffset = 0) {
const angleRad = angleDeg * Math.PI / 180;
return new Vector3(
Math.cos(angleRad) * radius,
yOffset,
Math.sin(angleRad) * radius
);
}
let frameCount = 0;
let lastTime = performance.now();
async function init() {
console.log('🚀 Инициализация Engine3D...');
// Получаем canvas и устанавливаем размеры на 100%
const canvas = document.getElementById('canvas');
canvas.style.width = '100vw';
canvas.style.height = '100vh';
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
await Engine3D.init({
canvasConfig: {
canvas: canvas,
devicePixelRatio: window.devicePixelRatio
}
});
console.log('✅ Engine3D инициализирован');
// Регистрация шейдеров
ShaderLib.register(VS_NAME, vsCode);
ShaderLib.register(FS_NAME, fsCode);
ShaderLib.register(COMPUTE_NAME, computeCode);
console.log('✅ Шейдеры зарегистрированы');
// Создание сцены
const scene = new Scene3D();
scene.name = 'TesseractScene';
// Камера
const cameraObj = new Object3D();
cameraObj.name = 'Camera';
const camera = cameraObj.addComponent(Camera3D);
camera.perspective(60, Engine3D.aspect, 0.1, 1000);
cameraObj.transform.localPosition = new Vector3(0, 5, 18);
cameraObj.transform.lookAt(new Vector3(0, 0, 0), new Vector3(0, 1, 0));
scene.addChild(cameraObj);
const controller = cameraObj.addComponent(HoverCameraController);
controller.setCamera(0, -15, 18);
scene.addComponent(AtmosphericComponent).sunY = 0.6;
const light = new Object3D();
light.addComponent(DirectLight);
scene.addChild(light);
const radius = 12;
const angles = [0, 72, 144, 216, 288];
const speeds = [0.5, 1.0, 1.5, 2.0, 1.2];
const scales = [0.8, 1.0, 1.2, 0.9, 1.1];
console.log('🔧 Создание 5 тессерактов...');
angles.forEach((angle, index) => {
const position = getCirclePosition(radius, angle, 0);
const container = new Object3D();
container.name = `Tesseract_${index}`;
container.transform.localPosition = position;
container.transform.localScale = new Vector3(scales[index], scales[index], scales[index]);
const comp = container.addComponent(TesseractComponent);
comp.speedMultiplier = speeds[index];
scene.addChild(container);
console.log(` • Инстанс ${index}: угол ${angle}°, позиция (${position.x.toFixed(1)}, ${position.z.toFixed(1)}), скорость ${speeds[index]}x`);
});
// Обновляем счётчик объектов
const objCountElem = document.getElementById('objectCount');
if (objCountElem) objCountElem.textContent = `Объектов: ${angles.length}`;
const view = new View3D();
view.scene = scene;
view.camera = camera;
Engine3D.startRenderView(view);
window.addEventListener('resize', () => {
const canvas = document.getElementById('canvas');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
camera.aspect = Engine3D.aspect;
camera.perspective(60, Engine3D.aspect, 0.1, 1000);
});
function updateFPS() {
const now = performance.now();
const delta = now - lastTime;
if (delta >= 1000) {
const fps = Math.round((frameCount * 1000) / delta);
const fpsElement = document.getElementById('fps');
if (fpsElement) fpsElement.textContent = `FPS: ${fps}`;
frameCount = 0;
lastTime = now;
}
frameCount++;
requestAnimationFrame(updateFPS);
}
lastTime = performance.now();
requestAnimationFrame(updateFPS);
console.log('✅ ТЕССЕРАКТЫ СОЗДАНЫ!');
console.log(' • 5 объектов на окружности');
console.log(' • 4D вращение через Compute Shader');
console.log(' • Разные скорости вращения');
}
init().catch(console.error);
</script>
</body>
</html>
Автор: zababurin
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/449629
Ссылки в тексте:
[1] документацию Orillusion: https://www.orillusion.com/en/guide/
[2] Unlit Material: https://www.orillusion.com/en/guide/advanced/shader/shader_unlit.html
[3] Источник: https://habr.com/ru/articles/1023432/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1023432
Нажмите здесь для печати.