はじめに
突然ですが、皆さんは Synthwave Sunset をご存じでしょうか。
Synthwave Sunset は端的に言うとこんな感じの画像です。
暗い夜空の中にポツンと太陽が鎮座する、神秘的な光景です。
このような画像を私は Synthwave Sunset と呼んでいます(正式名称は知りません)。この Synthwave Sunset というものは一部の界隈ではよく認知されており、実際に検索すると類似した画像が出てきます。
この記事は、この Synthwave Sunset のような画像を Siv3D の RenderTexture
の力で出力しようという試みを記録したものです。
レギュレーションとして、予め画像ファイルを用意してその画像を貼り付けるといった行為はNGとします。
ちなみにかなり力技です。
環境
Windows Home 10
OpenSiv3D v0.6.6
アルファブレンド
今回の試みにおいて、多くの場面で必要になったのがこのアルファブレンドです。
アルファブレンドについては以下の記事を参考にしました。
私的な理解では、不透明度を含む色の描画方法のことを指していると思っています。
Synthwave Sunset は太陽に霞がかかっており、一部分が透過し背景が透けて見えるのがわかります。
こういった部分はアルファブレンドの設定により表現しました。
BlendState bs;
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::Zero;
bs.opAlpha = Blend::Add;
OpenSiv3D では、 BlendState
によってアルファブレンドを設定できます。
この設定で src
を描画すると、 src
の不透明度で dst
側の不透明度を上書きする、というような挙動になり、これで src
側の描画領域の不透明度を 0 にすることで重なった部分を透過させるような描画ができます。
Synthwave Sunset の太陽なんかは、正にこれで表現できますね。
描画では基本的にひたすらこれを繰り返していきました。
アルファブレンドの注意点
重なった部分の不透明度が 0 となっていても、色の情報は残ることに気を付けなければなりません。
特にこれは、同じ RenderTexture
に異なる BlendState
を適用し描画しているときやガウシアンぼかしを使う際に注意したいです。
ライトブルームを作る際にこれが原因で黒が混じってしまう現象が発生し、かなり沼ってしまいました。
パーリンノイズ
描画の細かな歪を出すためにかなり重宝したのがパーリンノイズです。
パーリンノイズについては以下の記事を参考にしました。
私的な理解では、隣接する場所では滑らかに値が変化するようなランダム値を出力するアルゴリズムだと思っています。
個人的にはイージングと近しいものを感じています。
Synthwave Sunsetの遠景は、靄のようなものがかかり空の色が霞みがかっています。
こういった部分はパーリンノイズで表現しました。
PerlinNoise perlin;
for (int i = 0; i < image.height(); ++i) {
for (int j = 0; j < image.width(); ++j) {
double res = perlin.octave2D0_1((double)j / image.height() * 3, (double)i / Scene::Height() * 3, 5, 0.5);
image[i][j] = ColorF(0, 0, 0, res * 1.7);
}
}
Siv3D 上ではパーリンノイズは PerlinNoise
として実装されています。
octave2D0_1()
によって重ねられたパーリンノイズが生成できるようですが、 octave
を大きい値にし過ぎると生成に時間がかかるため、なるべくメインループ内での使用を避けるか、一度作ったパーリンノイズの画像を使いまわしたいところです。
遠景の靄の他、太陽の模様入れにも使っています。
格子が波打つような演出にもパーリンノイズを使いました。
動きの場合はあまり滑らかなグラデーションを必要としないので、単純に noise2D0_1()
でノイズを重ねないパーリンノイズを作ります。こっちはメインループ中に使っても動作が速いようで安心。
完成!
あとは、格子上に太陽を反射させ、星を散りばめて、全体が淡く見えるようなピクセルシェーダーをかければ完成です。
太陽の反射だったりライトブルームだったり、ほとんどの処理は Zenn で公開されている Siv3D の初心者向けチュートリアルを参考にしました。チュートリアルが本当に便利ですごい。
完成品はこんな感じになりました。
ソースコードを載せておきます。
ソースコードなど
# include <Siv3D.hpp>
void Main()
{
// ----- 宣言
// 描画要素
MSRenderTexture rt(Scene::Size());
RenderTexture rt_sun(Scene::Size());
MSRenderTexture rt_lattice(Scene::Size());
RenderTexture rt_star(Scene::Size());
Texture fog;
Image image(Scene::Size());
// ぼかし用
RenderTexture rt_tmp(Scene::Size());
RenderTexture gaussianA4(Scene::Size() / 4), gaussianB4(Scene::Size() / 4);
RenderTexture gaussianA16(Scene::Size() / 16), gaussianB16(Scene::Size() / 16);
RenderTexture gaussianA64(Scene::Size() / 64), gaussianB64(Scene::Size() / 64);
// PixelShader
PixelShader const ps(HLSL{ U"sunset.hlsl", U"PS" });
// Colorなど
Color col1 = Palette::Orange;
Color col2 = Palette::Black;
Color col3 = Palette::Magenta;
Color col_line1 = Palette::Blue;
Color col_line2 = Palette::Blue;
Color col_bg1 = Palette::Mediumorchid;
Color col_bg2 = Palette::Darkblue;
double y_start = Scene::Height() + 50; // 格子の描画開始位置y
double y_end = 400; // 地平線の位置
double line_thickness = 3;
Circle sun((Scene::Size() / 2).movedBy(0, -40), 150);
double line_t = 0;
int reso = 48;
Array<Triangle> tris;
Grid<Vec2> points(40, 10);
Array<Circle> stars(12);
PerlinNoise perlin;
// ----- 色々な設定
// 色の調整
col_line1 = col_line1.lerp(Palette::Black, 0.3);
col_line2 = col_line2.lerp(Palette::Black, 0.3);
// 円の三角形化
Polygon circ = sun.asPolygon(reso);
tris.reserve(circ.num_triangles());
for (int i = 0; i < (int)circ.num_triangles(); ++i) {
tris.emplace_back(circ.triangle(i));
}
// 適当な星
for (int i = 0; i < (int)stars.size(); ++i) {
// もしsunと被っていたら再生成する
do {
stars[i].center = RandomVec2(RectF(Vec2(0, 0), Vec2(Scene::Width(), sun.center.y + sun.r)));
} while ((stars[i].center - sun.center).lengthSq() < sun.r * sun.r);
stars[i].r = Random(1.0, 3.0);
}
for (int i = 0; i < image.height(); ++i) {
for (int j = 0; j < image.width(); ++j) {
double res = perlin.octave2D0_1((double)j / image.height() * 3, (double)i / Scene::Height() * 3, 5, 0.5);
image[i][j] = ColorF(0, 0, 0, res * 1.7);
}
}
fog = Texture(image);
while (System::Update())
{
// ----- Update
// line_tの更新
line_t = Periodic::Sawtooth0_1(10s);
// pointの位置更新
double x_end_w = 800, x_start_w = 10000;
for (int i = 0; i < points.height(); ++i) {
double y_ratio = EaseOutQuad((i + line_t) / (points.height()));
double x_w = Math::Map(y_ratio, 0.0, 1.0, x_start_w, x_end_w);
double x_margin = x_w / points.width();
double y = y_start - (y_ratio * (y_start - y_end));
for (int j = 0; j < points.width(); ++j) {
points[i][j].x = Scene::Width() / 2.0 - x_w / 2.0 + x_margin * j;
points[i][j].y = y - perlin.noise2D0_1(points[i][j].x * 0.08, y * 0.08) * (20 * EaseOutQuint(1 - y_ratio));
}
}
// ----- Draw
// 太陽 -----
rt_sun.clear(col1.withAlpha(0));
BlendState bs(BlendState::Default2D);
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::Zero;
bs.opAlpha = BlendOp::Add;
{
ScopedRenderTarget2D const target(rt_sun);
ScopedRenderStates2D const blend(bs);
// グラデーションSun
for (int i = 0; i < (int)tris.size(); ++i) {
Color p0col = col1.lerp(col3, Math::Map(tris[i].p0.y, sun.center.y - sun.r, sun.center.y + sun.r, 0.0, 1.0));
Color p1col = col1.lerp(col3, Math::Map(tris[i].p1.y, sun.center.y - sun.r, sun.center.y + sun.r, 0.0, 1.0));
Color p2col = col1.lerp(col3, Math::Map(tris[i].p2.y, sun.center.y - sun.r, sun.center.y + sun.r, 0.0, 1.0));
tris[i].draw(p0col, p1col, p2col);
}
}
// ぼかしを作る
Shader::GaussianBlur(rt_sun, rt_tmp, rt_sun);
Shader::Downsample(rt_sun, gaussianA16);
Shader::GaussianBlur(gaussianA16, gaussianB16, gaussianA16);
Shader::Downsample(gaussianA16, gaussianA64);
Shader::GaussianBlur(gaussianA64, gaussianB64, gaussianA64);
// sunの模様をfogから準備
{
ScopedRenderTarget2D const target(rt_tmp.clear(ColorF(0, 0, 0, 0)));
ScopedRenderStates2D const blend(bs);
fog.resized(sun.r * 2, sun.r * 2).draw(Vec2(sun.center.x - sun.r, sun.center.y - sun.r));
}
// fogの色と不透明度を調整
bs.srcAlpha = Blend::Zero;
bs.dstAlpha = Blend::One;
bs.opAlpha = BlendOp::Min;
bs.src = Blend::DestAlpha;
bs.dst = Blend::One;
{
ScopedRenderTarget2D const target(rt_tmp);
ScopedRenderStates2D const blend(bs);
Scene::Rect().draw(ColorF(1, 1, 1, 0.6));
}
// sunにfogを適用
bs.srcAlpha = Blend::Zero;
bs.dstAlpha = Blend::One;
bs.opAlpha = BlendOp::Add;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::InvSrcAlpha;
{
ScopedRenderTarget2D const target(rt_sun);
ScopedRenderStates2D const blend(bs);
rt_tmp.draw(AlphaF(0.8));
}
// sunの周りに飛び出た色を白にリセット
bs.srcAlpha = Blend::Zero;
bs.dstAlpha = Blend::One;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::DestAlpha;
{
ScopedRenderTarget2D const target(rt_sun);
ScopedRenderStates2D const blend(bs);
Scene::Rect().draw(ColorF(1, 1, 1, 0));
}
// 黒Line
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::Zero;
bs.opAlpha = BlendOp::Add;
bs.src = Blend::DestAlpha;
bs.dst = Blend::One;
bs.op = BlendOp::RevSubtract;
{
ScopedRenderTarget2D const target(rt_sun);
ScopedRenderStates2D const blend(bs);
int line_n = 6;
double thickness = 30;
double line_end = sun.center.y - 20; // lineの上側の終端
double line_start = sun.center.y + sun.r; // lineの上側のスタート位置
double margin = (line_start - line_end) / line_n;
double t = Periodic::Sawtooth0_1(3s);
for (int i = 0; i < line_n; ++i) {
double y = line_start - (t + i) * margin;
RectF(Vec2(sun.center.x - sun.r, y), Vec2(sun.r * 2, thickness * Math::Map(y, line_end, line_start, 0.0, 1.0))).draw({ Palette::White, 0 });
}
}
// ぼかし入れ
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::InvSrcAlpha;
bs.opAlpha = BlendOp::Add;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::InvSrcAlpha;
bs.op = BlendOp::Add;
{
ScopedRenderTarget2D const target(rt_sun);
ScopedRenderStates2D const blend(bs);
gaussianA16.resized(Scene::Size()).draw(AlphaF(0.5));
gaussianA64.resized(Scene::Size()).draw(AlphaF(1));
}
// 星 -----
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::Zero;
bs.opAlpha = BlendOp::Add;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::Zero;
bs.op = BlendOp::Add;
{
ScopedRenderTarget2D const target(rt_star.clear(AlphaF(0)));
ScopedRenderStates2D const blend(bs);
for (auto const& star : stars) {
Circle c = star;
c.r += Random();
c.draw();
}
}
// ぼかし入れ
Shader::GaussianBlur(rt_star, rt_tmp, rt_star);
Shader::Downsample(rt_star, gaussianA4);
Shader::GaussianBlur(gaussianA4, gaussianB4, gaussianA4);
Shader::Downsample(gaussianA4, gaussianA16);
Shader::GaussianBlur(gaussianA16, gaussianB16, gaussianA16);
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::InvSrcAlpha;
bs.opAlpha = BlendOp::Add;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::InvSrcAlpha;
bs.op = BlendOp::Add;
{
ScopedRenderTarget2D const target(rt_star);
ScopedRenderStates2D const blend(bs);
gaussianA4.resized(Scene::Size()).draw(AlphaF(0.8));
gaussianA16.resized(Scene::Size()).draw(AlphaF(1));
}
// 格子 -----
bs.srcAlpha = Blend::Zero;
bs.dstAlpha = Blend::Zero;
bs.src = Blend::One;
bs.dst = Blend::Zero;
{
ScopedRenderTarget2D const target(rt_lattice.clear(ColorF(1, 1, 1, 0)));
ScopedRenderStates2D const blend(bs);
// ぼかし用の下地として薄いグラデーション色を仕込んでおく
RectF(Vec2(0, y_end - 50), Vec2(Scene::Width(), y_start - y_end + 50)).draw(Arg::top = ColorF{ col_line1, 0 }, Arg::bottom = ColorF{ col_line2, 0 });
}
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::InvSrcAlpha;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::InvSrcAlpha;
{
ScopedRenderTarget2D const target(rt_lattice.clear(ColorF(1, 1, 1, 0)));
ScopedRenderStates2D const blend(bs);
// 格子
for (int i = 0; i < points.height(); ++i) {
for (int j = 0; j < points.width(); ++j) {
// 横線
if (j < points.width() - 1) Line(points[i][j], points[i][j + 1]).draw(line_thickness, Palette::White);
// 縦線
if (i < points.height() - 1) Line(points[i][j], points[i + 1][j]).draw(line_thickness, Palette::White);
}
}
// 地平線
Line(Vec2(0, y_end), Vec2(Scene::Width(), y_end)).draw(line_thickness, Palette::White);
}
Graphics2D::Flush();
rt_lattice.resolve();
// ぼかしを入れる
Shader::GaussianBlur(rt_lattice, rt_tmp, rt_lattice);
Shader::Downsample(rt_lattice, gaussianA4);
Shader::GaussianBlur(gaussianA4, gaussianB4, gaussianA4);
Shader::Downsample(gaussianA4, gaussianA16);
Shader::GaussianBlur(gaussianA16, gaussianB16, gaussianA16);
{
ScopedRenderTarget2D const target(rt_lattice);
ScopedRenderStates2D const blend(bs);
gaussianA4.resized(Scene::Size()).draw(AlphaF(0.8));
gaussianA16.resized(Scene::Size()).draw(AlphaF(0.8));
}
Graphics2D::Flush();
rt_lattice.resolve();
// 全体 -----
bs.srcAlpha = Blend::One;
bs.dstAlpha = Blend::InvSrcAlpha;
bs.src = Blend::SrcAlpha;
bs.dst = Blend::InvSrcAlpha;
// 背景とフォグ
{
ScopedRenderTarget2D const target(rt);
ScopedRenderStates2D const blend(bs);
// 背景
RectF(Vec2::Zero(), Scene::Size()).draw(Arg::left = col_bg1, Arg::right = col_bg2);
RectF(Vec2::Zero(), Scene::Size()).draw({ Palette::Black, 0.4 });
fog.draw();
}
// 背景 + sun + 格子
{
ScopedRenderTarget2D const target(rt);
ScopedRenderStates2D const blend(bs);
// fog
fog.draw();
rt_star.draw();
rt_sun.draw(AlphaF(1));
// ワイヤ
RectF(Vec2(0, y_end), Vec2(Scene::Width(), y_end)).draw(Palette::Black);
rt_lattice.draw();
// 全体を明るめに
RectF(Vec2::Zero(), Scene::Size()).draw(Arg::top = ColorF(1, 1, 1, 0.2), Arg::bottom = ColorF(1, 1, 1, 0));
rt_sun(RectF(0, 0, Scene::Width(), sun.center.y + sun.r - 40)).flipped().draw(Vec2(0, y_end), Arg::top = AlphaF(0.8), Arg::bottom = AlphaF(-1));
}
Graphics2D::Flush();
rt.resolve();
{
const ScopedCustomShader2D shader{ ps };
rt.draw();
}
}
}
//-----------------------------------------------
//
// This file is part of the Siv3D Engine.
//
// Copyright (c) 2008-2021 Ryo Suzuki
// Copyright (c) 2016-2021 OpenSiv3D Project
//
// Licensed under the MIT License.
//
//-----------------------------------------------
//
// Textures
//
Texture2D g_texture0 : register(t0);
SamplerState g_sampler0 : register(s0);
namespace s3d
{
//
// VS Output / PS Input
//
struct PSInput
{
float4 position : SV_POSITION;
float4 color : COLOR0;
float2 uv : TEXCOORD0;
};
}
//
// Constant Buffer
//
cbuffer PSConstants2D : register(b0)
{
float4 g_colorAdd;
float4 g_sdfParam;
float4 g_sdfOutlineColor;
float4 g_sdfShadowColor;
float4 g_internal;
}
cbuffer PSPixelSize : register(b1)
{
float2 g_pixelSize;
}
cbuffer PSCounter : register(b2)
{
float g_counter;
}
float rand(float2 co){
return frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43756.5453123);
}
float4 PS(s3d::PSInput input) : SV_TARGET
{
// return g_texture0.Sample(g_sampler0, input.uv);
float2 uv = input.uv;
// color
const float4 texColor = g_texture0.Sample(g_sampler0, uv);
float4 col = (texColor * input.color) + g_colorAdd;
// pattern
const float pattern = 1.0f / 120;
if (fmod(uv.y, pattern) < pattern / 2.0f) {
col *= float4(1.2f, 1.2f, 1.2f, 1);
}
const float r = rand(uv);
if (r < 0.2) {
col *= float4(1.12f, 1.12f, 1.12f, 1);
}
// glitter
if (fmod(g_counter, 0.06) < 0.03) {
col *= float4(0.99f, 0.99f, 0.99f, 1);
}
return col;
}
雑感・反省点
Synthwave Sunset の一つ一つの要素をつぶさに再現しようとしたところ、かなりコードが冗長になってしまいました。こういうコードを短く書ける人を見ると、本当に頭が下がります。
余計な描画も多かったり、ガウシアンぼかしを大量にかけていたりとパフォーマンス面でも気になるところが多いです。
特に MSRenderTexture
を描画可能にするための Graphics2D::Flush()
と resolve()
の実行コストが気になるところ。
何はともあれ、 Synthwave Sunset を拝むことができてよかったです。