Примечание редактора: С Конференция Three.js всего через деньмы не могли себе представить лучшего финала перед началом торжества. Сегодня мы рады обратить внимание Мин Джун Хун и Тем не менее, последняя глава в его продолжающейся истории космонавта. Вдохновленный Акирой и традиционным японским искусством, Минг Джун объединяет мультяшные тени, чернила, шелковые текстуры, процедурные цветы и генеративные завитки, чтобы создать мир, который кажется одновременно тщательно созданным и удивительно живым! Мы очень надеемся, что вам понравится эта удивительная разбивка!
🇫🇷 Завтрашний день! Первая в истории конференция Three.js почти готова к проведению в Париже. 🌸 Если ты уже рядом и все еще думаешь об этом, это твой последний шанс побывать там! Использовать код CODROPS для скидка 15% и возьми билет →
Все еще — это третья глава интерактивной истории космонавтов в реальном времени, которую я размещаю в сети (предыдущая статья о Codrops). Каждая глава продвигает историю вперед, давая мне новый визуальный и технический вопрос для исследования.
После того, как заблудился в Дрифтон наткнулся Ложная Земля день за днем, всегда в движении, никогда не прибывая. В конце концов, после того, что казалось вечностью, его настигла усталость, и он лег, зависший между бодрствованием и сном. В этом полусонном состоянии время теряет свои края. Земля, цветы и костюм начинают сливаться в один сплошной пейзаж, а его мысли мечутся между воспоминаниями и настоящим.
Создание стиля японской печати
Визуальное исследование этой главы началось с Акира. Моя связь с фильмом была эмоциональной до того, как он стал техническим: его визуальный стиль, атмосфера, звуковое оформление и музыка оставались со мной еще долго после того, как я его посмотрел, оставляя во мне желание создать что-то с таким же неизгладимым ощущением.
Затем я обратил внимание на традиционные японские картины и складные ширмы. Размещение Акира Помимо этих картин я заметил общую визуальную логику: плоские цветные области, четкие края, продуманная композиция, тканые поверхности и неровные знаки, которые создают атмосферу за счет формы, поверхности и пространства, а не реализма. В обоих случаях тишина и ритм имеют такое же значение, как и детали.
Я перевел эту общую логику в трехмерную сцену реального времени. Мультяшное затенение сохраняет плоскости широкими, тень от земли становится неравномерной промывкой чернил, шелковое переплетение поставляет материальное зерно, и процедурный рост придает цветам и усикам органичный ритм. Целью было не воспроизвести эти отсылки буквально, а перенести их ощущение композиции и материального присутствия в живую сцену.


Ксилографический мультяшный
Я хотел, чтобы астронавт и цветы имели один и тот же визуальный стиль, но для каждого из них требовались свои собственные методы затенения и обводки. Для затенения в костюме используется мультяшный материал с текстурированным альбедо, а для лепестков и стеблей используется материал цвета вершин, созданный для создания экземпляров НДС. Применение одного и того же квантованного освещения к обоим изображениям позволяет им читаться как одно изображение. Для контуров персонаж использует перевернутый корпус, а для лепестков — шейдер на основе маски.
Я начинаю с N·L, который измеряет, насколько поверхность обращена к свету: N — нормаль, а L — направление света. Я переназначаю его между thresholdLow и thresholdHighзатем используйте floor квантовать его в цветовые уровни. Я сохраняю результат на двух уровнях: одна теневая полоса и одна освещенная полоса, потому что больше шагов перестают читаться, как печать. Тень и свет — это оттенки основного цвета, и когда полосы выглядят слишком чистыми, я добавляю к порогу немного шума мирового пространства, чтобы сломать резкий край.
const ndl = max(dot(N, L), 0.0);
const thresholdNoise = fbm3(positionWorld.mul(thresholdNoiseScale))
.sub(0.5)
.mul(thresholdNoiseStrength);
const preShade = clamp(
ndl.sub(thresholdLow.add(thresholdNoise))
.div(thresholdHigh.sub(thresholdLow)),
0.0,
1.0,
);
const quantized = floor(preShade.mul(colorLevels.sub(1.0)).add(0.5))
.div(colorLevels.sub(1.0));
const litColor = mix(
albedo.mul(shadowTint),
albedo.mul(highlightTint),
quantized,
);
Контур выполнен в том же стиле, но реализация зависит от геометрии каждого актива. Для персонажа я использую перевернутый корпус: вторую сетку с задними гранями, выдвинутыми по нормали. Лепестки пропускают этот путь, потому что каждая головка НДС создается сотни раз, поэтому добавление еще одной сетки для каждой головки будет дорогостоящим и все равно приведет к неправильному силуэту: деформирующая сетка — это не вырез лепестка. Форма уже находится в текстуре маски, поэтому я отбрасываю внешнюю часть и рисую обод в шейдере на той же маске, что позволяет одной текстуре обрабатывать и форму, и край.
Ink-Wash Ground Shadow
Пока я смотрел Акира для этой главы я нашел этот плакат. Канеда и велосипед сидят на ровном белом фоне, но тень под ней — это та часть, которую я продолжаю замечать: мягкая по краям, неровная внутри и лишь слабо повторяющая силуэт, словно краска, разбавленная на бумаге. Поскольку остальная часть сцены выглядит нарисованной, я хотел, чтобы тень следовала той же логике.

Направленный свет уже дает мне карту теней. Я читаю это на земле, переворачиваю и пробегаю smoothstep чтобы превратить его в широкую размывку с мягкими краями. Одно шумовое поле ослабляет край и разбивает заливку, поэтому край и заливка изменяются вместе.
Чем темнее контур использует то же значение тени и располагается сразу за размытием, а не отслеживает силуэт, поэтому он остается привязанным к тени при чтении как отдельный нарисованный штрих. Более мелкий шум разрывает линию, поэтому она не проходит как один непрерывный край, в то время как fwidth сохраняет постоянную ширину экранного пространства. Я сочетаю стирку и леску с maxпоэтому перекрывающиеся маски не складываются и не делают результат слишком темным.
const shade = shadow(light).oneMinus();
const noise = fbm2(positionWorld.xz.mul(washScale));
const fill = smoothstep(washAt, washAt.add(washSoft), shade.add(noise.mul(washBleed)));
const wash = fill.mul(float(1.0).sub(noise.mul(washMottle).max(0.0)));
const wobble = mx_noise_float(positionWorld.xz.mul(contourWobbleScale)).mul(contourWobble);
const penWidth = fwidth(shade).mul(contourWidth).max(0.0001);
const line = float(1.0).sub(smoothstep(0.0, penWidth, shade.sub(contourShade.add(wobble)).abs()));
const shColor = mix(washColor, contourColor, line);
return mix(bg, shColor, max(wash.mul(washStr), line.mul(contourStr)));
Помимо размытия земли, я также хотел, чтобы цветы отбрасывали тени на персонажа, а не на себя. Для этого я использую вторую карту теней, содержащую только цветы: их создатели используют отдельный слой, а теневая камера света тени растений ограничена им. Свет разделяет положение основного источника света, но имеет нулевую интенсивность, поэтому он пишет глубину без добавления видимого света и удерживает персонажа за пределами карты.
Низкополигональная сетка НДС также служит прокси-сервером только для теней на отдельном слое рендеринга, поэтому для проходов теней не требуется полная геометрия лепестка.
Шелковое переплетение


Я смотрел на две японские складные ширмы из мальвы: Сакаи Хоицу и Огаты Кензана. Земля — это та часть, которую я продолжаю замечать: слабое переплетение, сетка, похожая на нити, и следы, которые сидят, как пятна, настолько неровные, что кажутся нарисованными вручную. Я хотел привнести это качество в сцену.
Я наложил это переплетение на весь кадр, потому что на этих экранах сетка и пятна принадлежат основе картины, а не одному объекту. Я масштабирую screenUV в ячейки нити, расшатывайте сетку, чтобы она не выглядела машинной, и объединяйте направления основы и утка в переплетение. Я сдвигаю тона нитей и умножаю более медленный шум для пятен, затем умножаю оба слоя на цвет сцены, чтобы текстура затемняла весь кадр.
const coord = screenUV.mul(vec2(aspect, 1.0)).mul(threadCount);
const x = coord.x.add(hash(floor(coord.y)).sub(0.5).mul(irregularity));
const y = coord.y.add(hash(floor(coord.x)).sub(0.5).mul(irregularity));
const warp = pow(abs(sin(x.mul(PI))), sharpness);
const weft = pow(abs(sin(y.mul(PI))), sharpness);
const checker = mod(floor(x).add(floor(y)), 2.0);
const weave = mix(warp, weft, checker);
const fabric = clamp(float(1.0).sub(strength.mul(float(1.0).sub(weave))).add(threadTone), 0.0, 1.0);
const blotch = float(1.0).sub(blotchStrength.mul(smoothstep(0.45, 0.95, stain)));
const overlaid = sceneColor.mul(tint).mul(fabric).mul(blotch);
Одно растение
Установив каркас, я перехожу к самой маленькой повторяющейся единице в саду: одному растению. Прежде чем построить поле вокруг него, я использую одно растение, чтобы определить его цветок, стебель, листья и жизненный цикл.
Цветок
Для цветочной анимации я использую Цветущие цветы Пакет блендера. Его узлы геометрии уже создают необходимое мне детальное движение цветения, поэтому я сохраняю эту анимацию и конвертирую ее в НДС с помощью того же рабочего процесса, который я использовал в Ложная Земляиспользуя добавить Весь процесс я сделал в Blender.

Поверх этого запеченного НДС лепестки оставляют по несколько штук, затем поднимаются и расходятся веером. Идею пролития я взял из Цветы и Люди от TeamLab, где цветок достигает своей максимальной точки непосредственно перед тем, как упасть. Сетка уже представлена в виде отдельных островов, поэтому я группирую вершины по связности и упаковываю идентификатор лепестка и опорную вершину в цвет вершины. Хэш этого идентификатора сдвигает время и изменяет подъем каждого лепестка, в то время как значение ослабления управляет как движением вверх, так и распространением наружу. Сочетание запеченного НДС с процедурным проходом позволяет мне сохранить детализированное авторское движение, одновременно добавляя вариации и управляемое поведение во время выполнения, что делает анимацию более живой и менее повторяющейся без повторного запекания оригинала.
const petalId = color.g; // island id, 0..1
const pivot = sampleVAT(color.b, frame); // same vertex, current bloom frame
const startJitter = fract(sin(petalId * 127.1) * 43758.5453);
const heightJitter = fract(sin(petalId * 127.1 + 7.13) * 43758.5453);
const t = clamp((shed - startJitter * stagger) / (1.0 - stagger), 0.0, 1.0);
const ease = t * t * (3.0 - 2.0 * t);
const shrunk = pivot.add(basePos.sub(pivot).mul(1.0 - ease));
const height = 1.0 + (heightJitter - 0.5) * 2.0 * riseVariance;
const lift = rise * max(height, 0.0) * ease;
const outward = normalize(vec3(pivot.x, 0.0, pivot.z));
const fan = rotate(outward, flowerRotation) * spread * ease;
const position = rotate(shrunk, flowerRotation) + flowerPosition
+ vec3(0.0, lift, 0.0) * stemLength
+ fan * stemLength;
Корень
Этот пакет также создает стебли в узлах геометрии, но эти стебли привязаны к конкретным цветам. Мне нужен был стебель, который можно было бы изменить в Three.js, поэтому я перестроил ту же основную идею: кривую, свернувшуюся в трубку. Кривая Катмулла-Рома с семенами начинается немного ниже земли, наклоняется к головке цветка и изгибается вбок. Я пробую эту кривую в кольца, расширяю основание и сужаю стержень к кончику.
const from = new THREE.Vector3(0, -BASE_BURY, 0);
const to = /* lean azimuth × stemLength */;
const bend = /* seeded sideways offset */;
const curve = new THREE.CatmullRomCurve3(
[
from,
from.clone().lerp(to, 0.25).add(bend),
from.clone().lerp(to, 0.75).add(bend),
to,
],
false,
'centripetal',
);
const scale = (1 - (1 - radiusAttenuation) * t) + baseFlare * (1 - t) ** 3;
Построив трубку один раз, я выращиваю ее с одним значением от 0 до 1. Фрагментный шейдер скрывает все, что находится за пределами фронта роста, а вершинный шейдер масштабирует каждое видимое кольцо от центральной линии и непрерывно перемещает фронт через его активный сегмент. Я использую то же значение, чтобы разместить головку цветка вдоль кривой, чтобы цветок оставался прикрепленным к концу стебля по мере его роста.
If(uv().x.greaterThan(growth), () => Discard());
const rScale = startScale + growth * (1.0 - startScale);
grown = center.add(positionLocal.sub(center).mul(rScale));
Лист
Установив стебель, я добавляю листья как отдельную смоделированную сетку. В шейдере я сгибаю каждое лезвие, начиная с тугого завитка и постепенно раскрывая его по мере роста листа.
Я помещаю несколько листьев в засеянные места вдоль средней части кривой, сортирую их от основания к кончику и чередую вокруг стебля. Каждый лист прикрепляется к поверхности трубки самостоятельно. tпоэтому его положение остается стабильным, пока стебель растет. То же самое значение роста от 0 до 1 затем раскрывает каждый лист после того, как передняя часть достигает точки крепления.
curve.getPointAt(t, P);
pos.copy(P).addScaledVector(outward, stemRadius * radiusScale);
const growFrac = smoothstep(attachT, attachT + GROW_WINDOW, stemGrow);
placed = attach.add(leafPos.mul(growFrac));
Жизненный цикл
Анимация имеет два уровня времени. На уровне растения каждый экземпляр имеет полный жизненный цикл с собственным возрастом и продолжительностью посева, поэтому растения могут находиться на разных фазах, используя одни и те же правила. В рамках этой временной шкалы стебель, цветок и листья получают свой собственный компонентный прогресс. Это не отдельные часы: жизненный цикл предприятия решает, когда сработает каждая часть, и ее…