По случаю фестиваля Циси уместно обсудить тему создания жизни. Хотя у вас может не быть возможности рассказать о многомиллионном бизнесе, с помощью JavaScript более чем достаточно, чтобы завоевать несколько миллионов пикселей на веб-странице — это наша тема на этот раз,Конвей Игра жизни.
Игра жизни Конвея — это клеточный автомат, изобретенный британским математиком Джоном Хортоном Конвеем (помните эту концепцию? Ее можно использовать для доказательства полноты CSS по Тьюрингу). Правила этой игры (т. е. реализация кода) чрезвычайно просты, но могут иметь вполне «эволюционный» эффект. Благодаря простоте правил, эту статью можно использовать как вводное руководство по WebGL — разумеется, на основе нашей собственной базовой библиотеки WebGL.Beamсильно упростил.
задний план
Так называемая игра в жизни на самом деле просто набор правил расчета, выполненных на двумерной сетке. Предполагая, что в каждой сетке есть ячейка, его жизнь и смерть в следующий момент зависит от количества живых клеток в соседних 8 сетках:
- Если вокруг слишком много живых клеток, клетка погибнет из-за нехватки ресурсов (инволюция).
- Если вокруг слишком мало живых клеток, он тоже погибнет от того, что слишком одинок (недонаселен).
- Сетка, в которой расположены мертвые клетки, также может перейти в жизнеспособное состояние (воспроизводство), если окружающие ее живые клетки находятся в соответствующем количестве.
Конкретная реализация этого набора правил может быть реализована свободно, среди которых наиболее классической является версия Конвея, но на самом деле она очень проста:
- Если текущая ячейка жива и количество окружающих живых ячеек равно 2 или 3, оставьте все как есть.
- Если текущая клетка жива, а количество окружающих живых клеток меньше 2, клетка умирает.
- Если текущая ячейка жива и вокруг более 3-х живых клеток, то ячейка умирает.
- Если текущая ячейка мертва, а вокруг есть 3 живые клетки, ячейка становится живой.
Поняв эти правила, легко интерпретировать следующее состояние цикла:
Относительно более сложной является модель устойчивого воспроизводства. так:
Но это явно не самое захватывающее зрелище. Эффект на заглавной картинке этой статьи намного сложнее, чем эти простые шаблоны, и позже мы увидим, как его кодировать.
Итак, с чего начнем? Прежде чем войти в WebGL, давайте потренируемся на простой реализации.
Наивная реализация
Если только даны вышеперечисленные правила игры жизни, а реализация и исполнение не ограничены, я полагаю, что многие коллеги-писатели должны иметь возможность писать их по своему желанию - до тех пор, пока они используют0
или1
изnumber[][]
Двумерный массив для хранения состояния, а затем обновления кадр за кадром:
function update (oldCells) {
const newCells = []
for (let i = 0; i < oldCells.length; i++) {
const row = []
for (let j = 0; j < oldCells[i].length; j++) {
const oldCell = get(oldCells, i, j)
const total = (
get(oldCells, i - 1, j - 1) +
get(oldCells, i - 1, j) +
get(oldCells, i - 1, j + 1) +
get(oldCells, i, j - 1) +
get(oldCells, i, j + 1) +
get(oldCells, i + 1, j - 1) +
get(oldCells, i + 1, j) +
get(oldCells, i + 1, j + 1)
)
let newCell = 0
if (oldCell === 0) {
if (total === 3) newCell = 1
}
else if (total === 2 || total === 3) newCell = 1
row.push(newCell)
}
newCells.push(row)
}
return newCells
}
Приведенный выше код является не чем иным, как двухуровневым циклом for, если вы его избегаете.get
Возможные индексы в функции выходят за границы. С вычислением новых данных рендеринг их в DOM или Canvas тривиален, поэтому я не буду вдаваться в подробности.
Однако эта наивная реализация явно непригодна для использования. Пока достигнута ячейка порядка 1000х1000, значит, нужно выполнять миллионы обращений к массиву и сравнений покадрово.Как ни делай "экстремальную оптимизацию" на уровне JS, пробиться сложно узкое место. Итак, что нам нужно? Ускорение, конечно!
Принцип ускорения
Хотя в WebGL много сложных концепций, большинство из них связано со специфическим сценарием приложения 3D-графики. Если вы отбросите эти концепции в сторону, вы также можете думать о графическом процессоре как оускоритель для цикла for. Пока задачи в цикле for не имеют последовательных зависимостей (распараллеливаемых), такие задачи можно легко сбросить на GPU для завершения вычислений.
С этой точки зрения мы можем получить простое и понятное представление о WebGL:
- Как предоставить данные для прохождения цикла for? Предоставляя текстуры, к которым можно получить доступ по координатам XY.
- Как определить диапазон интервала, проходимого циклом for? Через массивы координат вершин (буферы).
- Как написать конкретную логику расчета в цикле for? Шейдеры написаны на языке GLSL.
Следовательно, ментальную модель процесса рендеринга игры жизни, реализуемую WebGL, можно понимать следующим образом:
- Укажите прямоугольник (два треугольника), который заполняет экран в качестве экстента рендеринга.
- Предоставляет растровую текстуру с начальным состоянием в виде данных сетки.
- В шейдере, написанном на GLSL, каждая точка прямоугольника (так называемый фрагмент) представляет собой выборку текстуры из 8 точек.
- В соответствии с результатами выборки используйте простую логику if-else в шейдере для оценки и вывода цвета.
Однако это описание все же является чрезмерным упрощением. Есть еще одна критическая проблема для конвейера рендеринга WebGL: если пиксель визуализируется непосредственно на экране, его неудобно пересчитывать для вычисления состояния для следующего кадра (в частности,gl.readPixels
очень медленно). Для этого нам нужно использовать классическую концепцию двойного буфера и отобразить ее в шахматном порядке:
- Создайте две текстуры того же размера, что и цели рендеринга.
- При инициализации
canvas
илиimg
Исходное состояние при загрузке первой текстуры. - После входа в основной цикл предыдущая текстура чередуется на входе, а результат расчета шейдера выводится на другую текстуру.
Но такой рендеринг за кадром, результат чего-то, чего мы не видим. Так что, в конце концов, вам нужно использовать еще один простой шейдер для рендеринга обновленного состояния текстуры на экран кадр за кадром.
Звучит интересно? Давайте посмотрим, как использовать BeamсемантическиНапишите этот процесс.
Реализация на основе луча
существует"Как создать базовую библиотеку WebGL«В этой статье мы подробно рассказали об API Beam и соответствующих концепциях WebGL. Короче говоря, используя Beam в качестве концептуальной модели для рендеринга WebGL,На самом деле, просто вызовите отрисовку с шейдером и ресурсами.. так:
const beam = new Beam(canvas)
const shader = beam.shader(MyShader)
const resources = [
// buffers, textures, uniforms...
]
beam.draw(shader, ...resources)
здесь дляbeam.draw
Входящие ресурсы включают ранее упомянутые вершины (буферы) и текстуры (текстуры). В качестве примера сначала рассмотрим простейшее требование: еслиimg
В теге хранится начальное состояние игры жизни, как его отрендерить с помощью WebGL? у БимаBasic ImageВ примере приведена достаточно простая реализация:
// 用于渲染基础图像的着色器
const shader = beam.shader(BasicImage)
// 构造宽高均为 [-1, 1] 的单位矩形 buffer
const rect = createRect()
const rectBuffers = [
beam.resource(VertexBuffers, rect.vertex),
beam.resource(IndexBuffer, rect.index)
]
loadImage(url).then(image => {
// 构造并上传纹理
const textures = beam.resource(Textures)
// 'img' 对应于着色器中的变量名
textures.set('img', { image, flip: true })
// 在 clear 后执行 draw
beam.clear().draw(shader, ...rectBuffers, textures)
})
В приведенном выше коде основными новыми понятиями должны быть VertexBuffers и IndexBuffer. Это тоже один из камней преткновения для новичков, говоря простым языком: у прямоугольника 4 вершины, а если его разбить на 2 треугольника для WebGL, то всего получается 6 вершин. Чтобы избежать избыточности данных, мы используем VertexBuffer для загрузки координат 4-х вершин, а IndexBuffer содержит подстрочные индексы, описывающие «какой из 4-х точек соответствуют все 6 вершин треугольника».
Он выглядит немного круглым? Просто посмотрите журнал
rect
структура должна быть четкой.
Если у вас есть буфер вершин, фрагментный шейдер выполняется один раз для каждого пикселя, который он покрывает. И в этом дефолтеBasicImage
В шейдере мы будемimg
Соответствующее положение текстуры сэмплируется. В результате соответствующая позиция изображения заполняется пикселями. Поскольку интервал системы координат экрана WebGL также[-1, 1]
, поэтому эффект от приведенного выше кода заключается в автоматическом растягивании и отображении изображения, покрывающего всю область холста WebGL. Что касается этогоBasicImage
, по сути, это просто такая простая конфигурация шейдера:
const vertexShader = `
attribute vec4 position;
attribute vec2 texCoord;
varying highp vec2 vTexCoord;
void main() {
gl_Position = position;
vTexCoord = texCoord;
}
`
const fragmentShader = `
precision highp float;
uniform sampler2D img;
varying highp vec2 vTexCoord;
void main() {
vec4 texColor = texture2D(img, vTexCoord);
gl_FragColor = texColor;
}
`
export const BasicImage = {
vs: vertexShader,
fs: fragmentShader,
buffers: {
position: { type: vec4, n: 3 },
texCoord: { type: vec2 }
},
textures: {
img: { type: tex2D }
}
}
Концептуальное различие между вершинными и фрагментными шейдерами было представлено во вводной статье Beam. Здесь нам нужно позаботиться только о фрагментном шейдере, который представляет собой следующий код GLSL:
void main() {
vec4 texColor = texture2D(img, vTexCoord);
gl_FragColor = texColor;
}
На самом деле это можно примерно понять как цикл for, выполняемый в JS следующим образом:
function main (img, x, y) {
PIXEL_COLOR = getColor(img, x, y)
}
for (let texCoordX = 0; texCoordX < width; texCoordX++) {
for (let texCoordY = 0; texCoordY < height: texCoordY++) {
main(img, texCoordX, texCoordY)
}
}
Выполнение шейдеров на GPU полностью параллельно. в самый разgl_FragColor
Присваивание позволяет выводить результат вычисления фрагмента (пикселя) на экран.
Таким образом, если мы понимаем взаимосвязь между циклом for и шейдером, мы можем легко переписать простую версию Game of Life для процессора в шейдер:
uniform sampler2D state;
varying vec2 vTexCoord;
const float size = 1.0 / 2048.0; // 像素尺寸换算
void main() {
float total = 0.0;
total += texture2D(state, vTexCoord + vec2(-1.0, -1.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(0.0, -1.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(1.0, -1.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(-1.0, 0.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(1.0, 0.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(-1.0, 1.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(0.0, 1.0) * size).x > 0.5 ? 1.0 : 0.0;
total += texture2D(state, vTexCoord + vec2(1.0, 1.0) * size).x > 0.5 ? 1.0 : 0.0;
vec3 old = texture2D(state, vTexCoord).xyz;
gl_FragColor = vec4(0.0);
if (old.x == 0.0) {
if (total == 3.0) {
gl_FragColor = vec4(1.0);
}
} else if (total == 2.0 || total == 3.0) {
gl_FragColor = vec4(1.0);
}
}
Просто заменив приведенный выше пример рендеринга изображения Beam этим шейдером, мы сделали важный шаг: первая эволюция жизни!
После первого раза, как добиться устойчивого двойного счастья? Разумеется, две радости (зачеркнуто, два буфера) накладывались друг на друга. В нативном WebGL это включает в себя ряд операций над FramebufferObject/RenderbufferObject/ColorAttachment/DepthComponent/Viewport. Но Beam сильно упрощает семантику этого процесса. в его образцовом сопровожденииoffscreen2D
метод,«Цель рендеринга» может быть абстрактно выражена в области видимости функции.. Например такая логика:
// 渲染到屏幕
beam
.clear()
.draw(shaderA, ...resourcesA)
.draw(shaderB, ...resourcesB)
.draw(shaderC, ...resourcesC)
Это можно легко изменить на рендеринг вне экрана следующим образом:
// 初始化离屏渲染的 target
const target = beam.resource(OffscreenTarget)
// 将 target 和纹理连接起来
const textures = beam.resource(Textures)
textures.set('img', target)
// 渲染到纹理,已有的渲染逻辑完全不变
beam.clear()
beam.offscreen2D(target, () => {
beam
.draw(shaderA, ...resourcesA)
.draw(shaderB, ...resourcesB)
.draw(shaderB, ...resourcesC)
})
// 在其他着色器中,现在即可使用 textures 下名为 'img' 的纹理
Обратите внимание, что мы не можем «напрямую» выполнить рендеринг в текстуру, но сначала нам нужна цель (фактически объект фреймбуфера), подключить эту цель к текстуре, а затем выбрать рендеринг в эту цель.
offscreen2D
На самом деле это настраиваемая команда. Вы также можете разработать больше операций конвейера рендеринга, которые «необходимо очистить», и инкапсулировать их в виде команд для выражения более сложных комбинаций с глубоко вложенными функциями, что является еще одной мощной функцией Beam.
Поняв этот API рендеринга за кадром, мы можем использоватьoffscreen2D
Возможность легко получить эффект чересстрочного рендеринга тоже:
const targetA = beam.resource(OffscreenTarget)
const targetB = beam.resource(OffscreenTarget)
let i = 0
const render = () => {
// 交错切换两个 target
const targetFrom = i % 2 === 0 ? targetA : targetB
const targetTo = i % 2 === 0 ? targetB : targetA
beam.clear()
beam.offscreen2D(targetTo, () => {
conwayTexture.set('state', targetFrom)
beam.draw(conwayShader, ...rectBuffers, conwayTexture)
})
// 将交错渲染的结果,借另一个简单着色器输出到屏幕上
screenTexture.set('img', targetTo)
beam.draw(imageShader, ...rectBuffers, screenTexture)
i++
}
Просто подготовьте начальное состояние, затем используйтеrequestAnimationFrame
чтобы вызвать это кадр за кадромrender
функция подойдет. Сгенерируйте серию случайных точек через Canvas, вы можете получить тестовый эффект следующим образом:
Таким образом, указывается, что процесс критического параллельного ускорения завершен. Наконец, внеся несколько изменений во фрагментный шейдер, вы можете добавить эффект «остаточного изображения» стоимостью 50 центов:
float decay = 0.95; // 衰减参数
// gl_FragColor = vec4(0.0); // 不再直接置空
gl_FragColor = vec4(0.0, old.yz * decay, 1.0);
// 后续的 if-else...
В этом весь смысл! Теперь мы можем испытать Game of Life Конвея, отрендеренную на GPU. У него есть некоторые предустановленные классические входные состояния, например жизнь в виде таких вот «осцилляторов»:
Вы также можете собирать огромные конструкции, которые постоянно «выращивают жизнь»:
Есть также такое небольшое посевное безумие:
А вот личный фаворит - "казалось бы единообразное коллективное расширение до границы Шикэ Ичудзикуй":
На этом апплет WebGL готов. С поддержкой WebGL для размера сетки 2048x2048 не проблема просчитать кадр за кадром более 4 миллионов пикселей. Вы можете получить доступ к нему здесьDemo. Наконец, некоторые статьи по теме, на которые стоит ссылаться:
Суммировать
На самом деле, простая демонстрация, такая как Game of Life Конвея, очевидно, является просто уловкой, которую профессиональные разработчики графики «брезгуют» объяснять. Но здесь, очевидно, есть большой пробел: обычному разработчику интерфейса обычно не хватает понимания и использования концепций WebGL. В связи с этим, пожалуй, нужно больше примеров такого рода забавы и простоты, чтобы всем было понятно, что «оказывается, это как раз тот случай».
Особо стоит упомянуть (удар), что все приложение Conway Game of Life, основанное на Beam, имеет сжатый объем всего5.6KB, чем тот, который используется реализацией сообществаLightGL DemoВерсия более чем на 90% меньше. Но Beam — это не игрушка, а общая базовая библиотека WebGL. Вы также можете использовать его для обученияОбработка изображений WebGL, мы даже используем его для рендеринга материалов с поддержкой PBR в веб-редакторе.3D текст. Его чрезвычайно легкий размер позволяет очень легко встраивать его в другие интерфейсные фреймворки. Есть много идей, которые стоит изучить в этом отношении, но они еще не реализованы, и я надеюсь, что вы поддержите их большим количеством звезд!
Короче говоря, «рендеринг» — это не просто виртуальные DOM-уровневые вещи, о которых снова и снова говорят в вопросах на собеседовании, это также своего рода расчет, который содержит бесконечные возможности. Как технология, которая не была полностью разработана фронтенд-индустрией, использование WebGL не обязательно требует знаний в области 3D, его можно использовать в качестве мощного ускорителя для обработки конкретных вычислительных задач (параллельные циклы for). Хотите узнать, как стать акселератором WebGL? Добро пожаловать, чтобы попробовать Beam, почувствовать внутреннюю часть 10 КБExpressive WebGLНу~