MineGraph Docs Help

进阶延迟处理 · 环境 Part 1

上一节我们成功为场景添加了还算能看的光照,但仍有一个不可忽视的问题:场景没有纵深感。在现实生活中,由于大气的存在,从远处物体进入我们眼中的光线会被空气中的小分子散射,最终看起来就像消隐进天空了一样,这些散射粒子就是所谓的雾气。雾气浓度与场景纵深挂钩,还可以在一定程度上用来遮瑕,并且单纯地绘制雾气的成本相对较低。

简单水平雾

雾气有很多处理方法,让我们从最简单的深度雾开始。和之前一样,说到位置信息,我们就离不开深度图。还记得我们第一章第一节中第一次体验延迟处理效果时吗?当时我们构建了一个获取线性深度图的函数 float LinearizeDepth(float depth) 用来处理那个奇怪的饱和度淡入特效 ,原来你一直与我同在 。它可以返回场景的视口空间 Z 值:

uniform float near; uniform float far;
float LinearizeDepth(float depth) { float z = depth * 2.0 - 1.0; return (2.0 * far * near) / (far + near - z * (far - near)); }

和之前的思路一样,我们需要将线性深度除以 far 来获取归一化线性深度:

float depth_linear = LinearizeDepth(depth); float depth_normalized = depth_linear / far;

这一次,我们依旧用 mix() 函数来进行处理,只不过我们这一次要混合的不再是饱和度,而是雾色。和天空颜色一样,OptiFine 也为我们提供了当前群系的雾色 fogColor

uniform vec3 fogColor;
vec3 fogColorG = vpow(fogColor, GAMMA); // 记得转到线性空间!
#include "/libs/Atmosphere.glsl" void main() { fragColor.rgb = mix(fragColor, fogColorG, pow(depth_normalized, 2)); }

你应该已经注意到了,我们使用线性深度的平方,这样可以让雾气在近处衰减得更快,以免让整个场景都雾蒙蒙的。现在我们已经可以看出很明显的场景纵深了:

深度雾

当然,你也能明显看出,这种雾气受镜头朝向影响,雾气边缘看起来就像是一个随着镜头运动的平面,Minecraft 早期的流畅迷雾就是如此处理的。一个更好的衰减方案,是使用场景相去视角的水平距离,而不是单纯的 Z 轴:

float fogDensity = pow(min(length(worldPos.xz), 1.0), 2); fragColor.rgb = mix(fragColor, fogColorG, fogDensity);

一定要记得钳制距离的最大值,因为归一化坐标围成的形状是矩形,对边距离最大可能会达到 ,导致混合出错。

水平雾

虽然我们的场景已经初具纵深,但你很快就会发现天空也因为其处于最大深度而一并被雾气遮蔽,虽然我们可以直接使用天空判定来禁用雾气,但这会导致很难看的断层,就像原版那样。

边界附近的物体与天空有明显断层

我们希望找到一种可以同时遮蔽环境和天空,还不会断层的雾气,这就是我们的下一步计划。

衰减

在现实中,受重力的影响,散射粒子不会在大气中均匀分布,倾向于散射环境光的大质量粒子总体分布在比较低的海拔上,而倾向于散射日光形成蓝色天空的细小分子则分布得更广。最终地平线附近被雾气遮蔽,而天空却依然清晰可见。

因此,除了水平上的衰减外,我们的雾气也还应该在竖直方向上进行衰减。之前的雾气我们都在视口空间完成,这和雾气越远越浓天然契合,然而在竖直方向上,固定海拔的雾气基本浓度不会随着视角变动而改变,因此我们需要使用绝对世界空间 轴坐标。

Minecraft 的普通世界海拔默认为 63,因此我们可以将海平面的雾气浓度设置为 1,往下不再增加,往上逐渐衰减,直到世界的建筑高度 320 层衰减至 0。当然,这些衰减方式也全凭你自己的喜好。

float altitude = worldPos.y + cameraPosition.y; fogDensity *= pow(remapSaturate(320.0, 63.0, altitude), 2);

这样,我们就完成了基本的高度雾:

高度雾

当然,目前的雾气缺陷仍然很明显,如果我们抬头观察同一竖直线上的表面,由于高空中竖直方向上雾气浓度已趋于 0,简单地将高度雾浓度与水平雾浓度相乘会导致远处的物体变得更加清晰,而近处的物体反而被笼罩在雾中。直觉上来说,高处的物体应该消隐得更慢,但不可能比同一条线上其他近处的物体更加清晰。

简单高度雾浓度变化

出现的问题也很正常,因为我们在水平方向上通过 俺寻思 考虑到了雾气随距离远去的累积效应 ,但在竖直方向上却直接取用了片元处的雾气浓度乘数,换句话说, 没有完全考虑从视口到片元的视线上累积的雾气 。当我们斜向上看向远处时,片元的距离与雾气累积浓度的关系看起来就像这样:

简单高度雾的距离-浓度曲线

另一个问题是,简单的幂和线性衰减过于生硬。描述大气衰减的压高公式指出,地球的大气是随海拔上升呈指数衰减的,而可见性与雾气浓度也不是简单的线性关系。因此,我们需要一个能更完美地描述雾气浓度的公式。

指数衰减积分雾

如果我们视同一海拔上的大气浓度为恒定值,那么雾气浓度就可以改写为:

其中 表示高度, 表示雾气基础密度, 表示随高度的衰减速率。

在视线方向上的雾气总浓度为:

其中积分区域 为摄像机与目标点的连线, 表示线上的距离, 分别是两点的相对坐标差值, 表示摄像机与目标点的世界空间高度, 。该式也可以扩展到三维空间中,只需要将 项更改为 。这个公式是方向无关的,因此积分式和坐标差还要取绝对值。

实际应用时,这个算式可能会在视线水平时出现除 0 错误,尽管我们可以用瞪眼法看出 ,但 GPU 不会自主处理这种极限情况。因此我们可以手动判定,在极限情况下替换为原函数:

当然,考虑到浮点和除法精度, 不能严格等于 0,可以取 作为极限逼近。

最后的最后,求场景的可见性 就比较简单了,它与雾气浓度相关的函数可以写成 ,其中 是与雾气和距离的相关系数,也就是我们之前求到的

好了,烦人的原理部分到此为止,我们来开始翻译 GLSL 吧。为了便于随时确认,我们将公式誊写下来:

这里我们取极限逼近函数使用了片元的高度 而不是视口高度 ,在极端情况下 能随片元的细微高差而变化。

我们先来处理公式中的那些常量,包括自然常数 、基础浓度 和衰减速率 。基础浓度和衰减速率将来都会加入设置中,因此我们用宏定义它们:

#define FOG_BASE_DENSITY 0.1 #define FOG_HEIGHT_FALLOFF 0.02 #define FOG_START_HEIGHT -120

我们会在线路上进行积分,每个微元的浓度就不必太高了,衰减系数在双重负指数( )上,我们应当只在千分位上微调。此外,我们还添加了一个 FOG_START_HEIGHT 用于偏移函数零点,它会被加到 上,在这个高度以下的海拔浓度会超过 1 并指数增长,用于遮蔽虚空中的物体。

自然常数 全部都用于指数函数,GLSL 为我们提供了内建的 exp(float x) 来计算 ,所以我们不需要再单独声明它。

接着再来看里面的变量,说来也不多,只有片元距离 、高差 和视口与片元的海拔

高差和片元距离都是相对数据,因此我们可以直接在相对世界空间求解它们。 非常简单,就是片元坐标的 分量取绝对值,距离则是片元的模长。注意到距离 总是和浓度 一起出现,我们可以提前将它们合并。

float rhoL = length(pos) * FOG_BASE_DENSITY; float deltaY = abs(pos.y);

摄像机和片元的海拔可以像简单高度雾那样直接使用 cameraPosition.y 求得,只需要再减去我们之前用来偏移浓度曲线的 FOG_BASE_DENSITY 就好:

float camAltitude = cameraPosition.y - FOG_START_HEIGHT; float height = pos.y + camAltitude;

最后,取上我们的极限,如果在小于极限值则使用原函数:

bool limited = deltaY < 1e-12;

准备工作全部完成,可以来求 了。先看原函数 ,拆成线性写法就是

翻译成 GLSL 之后就成了

const float f = FOG_BASE_DENSITY; fogDensity = rhoL * exp(-f * altitude);

积分式 也类似,线性写法是

翻译成 GLSL 就是

fogDensity = rhoL / (f * deltaY) * abs(exp(-f * altitude) - exp(-f * camAltitude));

最后,我们使用雾气的负指数作为可见性

float visibility = exp(-fogDensity);

需要注意,我们现在混合的是场景可见性而不是雾气浓度,因此要记得交换 fogColorGfragColor

fragColor.rgb = mix(fogColorG, fragColor.rgb, visibility);

最终,我们完成了这个(或许不那么)艰巨的任务,让空间蒙上了一层神秘的面纱 (你知道的,雾气这玩意就跟遮瑕粉一个道理) 。由于我们使用指数积分,因此雾气的浓度与场景的视距进行了解耦,在大视距下由于天空的视口空间 Z 值会随之增大 1 ,天边的雾气也会随之变浓。此外,又因为我们使用了负指数的 Visibility,现在场景的遮蔽强度永远都不会超过 1 了。

指数高度积分雾

[1] 天空总是会将 Z 值设置为远平面的值。如果视距仅为 2 区块,则区块边界的距离为 32,视口的远平面也就会被设置在这个距离附近。如果视距为 32 区块,则天边的区块边界的距离高达 512,天空就也被拉远了。

一个让雾气看起来更鲜艳有层次的小技巧是根据光源方向权重来叠加使用天空颜色和雾色,比如:

float ambientFogFactor = max(dot(lightDir, -viewportDir), 0.0); vec3 fog = skyColorG + fogColorG * pow(ambientFogFactor, 4);
染色雾气

在下个小节中,我们会处理天空渲染,并顺带完善雾气的光照。这些雾气性能逐级降低,因此你也可以视情况保留其他种类的雾气。

此外,也别忘了让雾色也影响环境光照:

float fogLumiFactor = max(dot(lightDir, surfaceNormal), 0.0); // 环境光照相对来说可以更加平缓一些 vec3 ambientColor = skyColorG + fogColorG * pow(fogLumiFactor, 2); float litAmbient = [... 计算环境光照 ...]; litAmbient *= AMBIENT_BRIGHTNESS * lightmap.t * albedo.a * ambientColor;

最后也要记得将相应的功能整理封装成函数存入 Atmosphere.glsl 中,以便修改和查阅!

简单大气

地球上有一层厚厚的大气保护着脆弱的生物圈,它不仅会散射强烈的宇宙射线,同时还会因为各种理化因素产生许多奇妙的效果。

我们之前已经完成了来自外太空的光照和贴近地表的雾气,然而目前它们仍然只是很机械地为地表提供恒定的光照和遮蔽。在这一小节中,我们会着手处理大气对它们的影响,让死板的天空更富活力。

日光、月光与环境

在第一章中,我们已经将场景的直接光照来源设置为了当前在场景中投影的太阳或月亮,然而,目前的光照颜色和亮度并不会随时间变化。在现实中,太阳光会被大气散射,从而在清晨和傍晚变得昏黄,而月光则是月亮反射来自太阳的光线,在晴朗的月圆午夜为地表提供微弱照明。

要想模拟大气光照就逃不开由瑞利散射(Rayleigh Scattering)和米氏散射(Mie Scattering)为基底构建的物理天空体系,但在进入物理渲染之前,我们不妨先利用一些 俺寻思之力 手动控制日光和月光的亮度与颜色。

正午日光色温约为 5000K (255, 231, 204),在傍晚时则更接近 2000K (255, 141, 11),在清晨,为了区别于日落,我们还可以将颜色设置得偏粉。虽然月光在现实世界中是暖色,但在艺术化作品中我们更倾向将它设置为 8000K (227, 233, 255) 的冷色(你也可以考虑到将傍晚和黎明的月光设置得更偏黄)。我们先将它们设置好:

const vec3 SunNoonColor = vec3(1.0, .91, .8); const vec3 SunSetColor = vec3(1.0, .55, .04); const vec3 SunRiseColor = vec3(1.0, .45, .31); const vec3 moonColor = vec3(.89, .91, 1.0);

不同时间段的光照亮度也会不断变化,因此我们还需要更多有关世界时间的变量。还记得我们在第一章的某个小知识中介绍的有关时间的统一变量吗?

uniform int worldTime; uniform float sunAngle; uniform float shadowAngle;

在启用 doDaylightCycle1.21.11 25w43a 及以前advance_time1.21.11 25w44a 及以后 规则的世界中,时间刻 worldTime 每游戏刻增加 1,每个游戏日共 24000 时间刻,即现实世界 20 分钟。

表达太阳和投影源在天空中 真·百分度 (1.0 = 360°)的 sunAngle) 和 shadowAngle) 就基于世界时间, sunAngle 指示了太阳的位置,而 shadowAngle 则指示了目前投影源的位置。

  • 从上个游戏日的第 23215 刻到第二个游戏日的 12785 刻,阴影空间原点在太阳位置,此时

  • 在 12786 刻时,太阳刚刚落到地平线以下,阴影空间原点切换到月亮位置,此时

  • 12786 ~ 23214 刻,阴影空间原点处于月亮位置,此时

  • 同样,在第 23215 刻时,阴影空间原点切换到太阳位置,此时

OptiFine 没有直接提供月光的角度,我们可以直接使用 moonAngle = fract(sunAngle + 0.5) 来求得, fract(x) 函数的内部实现为 x - floor(x) ,对于正数,它可以返回小数部分。

有了颜色和时间,我们就可以通过插值来在指定的时间段混合出指定的颜色了。太阳和月亮在游戏中说到底还是两个不相关的光源,因此,我们会将它们的颜色拆分,月光颜色是恒定的,我们就不需要额外的变量进行插值了。此外,为了让光照的亮度与颜色解耦,我们还会额外单独处理两组亮度参数。

vec3 sunColor; float sunBrightness; float moonBrightness;

根据 经验 估计,日落持续时间约为 ,而日出则是 ,日出跨越了两天时间,因此区间 是次日。

我们依旧使用 smoothstep(a, b, x) 来处理它们,还记得它的用法吧,将 上平滑地映射到 。对于日落来说很简单:

float sunSetRatio = smoothstep(.4, .5, sunAngle);

而对于日出来说,由于其跨越了一天, sunAngle 会归零,因此我们需要在归零的两段都进行额外插值然后相加:

float sunRiseRatioDay1 = smoothstep(.9, 1.2, sunAngle); float sunRiseRatioDay2 = smoothstep(-.1, .2, sunAngle); float sunRiseRatio = sunRiseRatioDay1 + sunRiseRatioDay2;

你会发现我们并没有完全按照日出和日落的规律设置光照颜色,这是因为在日出前半段和日落后半段,太阳亮度,场景无法受到阳光照射,下面设置光照强度时我们也会考虑到这一点。

由于 时会为 1,进而在 时导致 ,因此我们需要在 时归零

float sunRiseFactor = float(sunAngle <= .2); float sunRiseRatioDay1 = [...]; float sunRiseRatioDay2 = [...]; sunRiseRatioDay2 *= sunRiseFactor; float sunRiseRatio = [...];

然后就到了我们的超绝穷举时间:

bool isSunRise = sunAngle >= .9 || sunAngle <= .2; bool isSunSet = sunAngle >= .4 && sunAngle <= .5; sunColor = isSunRise ? mix(SunRiseColor, SunNoonColor, sunRiseRatio) : isSunSet ? mix(SunNoonColor, SunSetColor, sunSetRatio) : SunNoonColor;

我们不关心日落之后的时间,因为那时日光已经不再影响场景色彩了,因此我们将日出和日落之外的时间全部设置为了正午的颜色。

亮度也可以使用类似的方法,因为亮度是周期性变化的,因此我们可以使用 smoothstep() 配合加减法来随时间调整亮度:

sunBrightness = smoothstep(0.0, .1, sunAngle) - smoothstep(.45, .48, sunAngle); sunBrightness *= SUN_BRIGHTNESS;

你或许注意到了,我们将插值时间点偏移了一些,这是为了模拟在日月交替的时候亮度骤降的效果 所谓黎明前最黑暗的时刻 ,并且还可以用来作为阴影空间突变的缓冲和遮瑕。这也是之前我们将光照颜色变化集中在日照区间的原因之一。

类似的,月光亮度也可以使用这样的方法进行处理:

[... Settings ...] #define MOON_BRIGHTNESS 0.2 [... Final ...] moonBrightness = smoothstep(.52, .6, sunAngle) - smoothstep(.85, .97, sunAngle); moonBrightness *= MOON_BRIGHTNESS;

最后,我们将亮度和颜色乘入之前的直接光照公式中:

float litDirect = [... 计算直接光照 ...]; litDirect *= sunBrightness * sunColor + moonBrightness * moonColor;

为了让环境光照在日月交替 这段至暗时刻 中不那么突兀,我们也可以将日月光照作为额外系数乘入环境光强度中:

litAmbient *= sunBrightness + moonBrightness;

现在场景中的光照强度和色彩终于会随着时间的变化而变化了!

动态变化的光照

月相

除了时间之外,Minecraft 还存在 月相 ,当太阳被地球阻挡而无法照亮月球时,月球产生的漫反射理应减少,来自月亮的“直接”光照就会减弱。月相与世界日挂钩,从第一天的满月开始到第八天的盈凸月为一个周期。Optifine 为我们提供了月相变量:

uniform int moonPhase;

它的值域为 ,在第五天月相为新月时, moonPhase == 4 ,其余时间月相的对应光照亮度以新月为中心对称,我们可以据此计算得到月相的亮度级别 abs(moonPhase - 4)

当场景为满月时, ,如果将满月时的光照系数视为 1,则可根据亮度级别求得光照系数:

float moonPhaseLuminance = float(abs(moonPhase - 4)) / 4.0;

最后,将月相亮度乘入月光即可:

moonBrightness *= moonPhaseLuminance;
不同月相带来的场景亮度变化

天气

除了日月循环,天气对光照的影响也不容忽视。在 Minecraft 中存在三种天气:晴天、雨天和雷暴。而雨天和雷暴视群系而定,又会出现降雨、降雪和阴天三种情况。

在本小节中,我们主要着重于光照的变化,其他效果,例如水坑和雨雪等会在今后的章节逐步添加。OptiFine 只提供了晴雨的转换,因此我们只能将雷暴按照雨天处理。

当天气由晴转雨时,天空颜色会转为灰色,光照变得柔和,让场景的观感饱和度也慢慢降低,此外,露天表面的反射率也会增加。为此,我们需要获取当前的降雨强度和“湿度”:

uniform float rainStrength; uniform float wetness;

rainStrength 表征了当前降雨的强度,而 wetness 则可以用于表征地表的“湿度”。 wetness 由湿润半衰期和干燥半衰期控制:

const float wetnessHalflife = 600.0f; const float drynessHalflife = 200.0f;

湿润半衰期控制由湿转干时的值跌落至起始值一半的游戏刻,默认为 600 刻,干燥半衰期则控制由干转湿 ,默认 200 刻。晴转雨时空气的湿度会迅速上升,而雨转晴后湿度降低会相对较慢,因此我们就沿用默认的设置了。

天气变化时降雨强度和湿度的变化动态

画面右侧显示了降雨强度和湿度在湿润半衰期为 5、干燥半衰期为 1 下晴雨切换时的值变化情况。可以看到降雨强度变化与天空色和雾色的变化直接同步。

被云层遮挡的光照

让我们从光照开始。当降雨强度增大时,由于太阳被云层遮挡,光照会减弱,再加上云层的向内散射,阴影也会由于“光源”的扩散而逐渐模糊 1 。因此,我们可以在直接光照和阴影的 PCF 半径上动手脚。

[1] 太阳附近的云层会透射更多的太阳光,从而形成一个大范围的柔和间接光源。

雨天的直接光照完全消失不太好看,也会让模糊的阴影失去意义,因此我们保留 0.1 倍左右的光照强度。光照强度与降雨强度为负相关,我们需要手动映射它们。在此推荐一个类似 remap() 的线性映射函数:

#define remap2(a,b,c,d,x) ((x-a)/(b-a)*(d-c)+c)

它可以将 线性映射到 上,或者说函数图像是一条过 两点的直线。它的配套规整函数

#define remap2Saturate(a,b,c,d,x) clamp(remap2(a,b,c,d,x), min(c,d), max(c,d))

可以将映射后的值限制在 上。映射是无序的,因此 可以与 成对交换,即

我们希望在降雨强度为 0 时光照强度为 1,降雨强度为 1 时光照强度为 0.1,因此我们需要将它们的关系映射到过 点的直线上,我们将两点代入 remap2Saturate() 中,就可以求得光照强度:

[... Settings ...] #define RAIN_BRIGHTNESS 0.1 [... final.glsl ...] float litDirect = [... 计算直接光照 ...]; litDirect *= sunBrightness * sunColor + moonBrightness * moonColor; float rainFactor = remap2(0.0, 1.0, 1.0, RAIN_BRIGHTNESS, rainStrength); litDirect *= rainFactor;

对于阴影的模糊,我们可以仿效光照强度,将 PCF 的半径倍率作为一个与降雨强度相关的系数:

[... Settings ...] #define PCF_RAIN_FACTOR 5.0 [... Lighting ...] float calcPCF(...) { [...]; for(...) for(...) { vec2 steps = [...]; steps *= remap2(0.0, 1.0, 1.0, PCF_RAIN_FACTOR, rainStrength); } }

在低亮度下,大半径小采样的 PCF 带来的亮度断层不明显,也不会产生太多穿帮。现在,雨天的光照和阴影看起来也非常不错了:

雨天的 PCF

被雨水打湿的材质

雨水落到物体表面后可以分为两种情况:在表面滞留或者被材料吸收。在本小节,我们暂时不考虑不规则的低洼地带导致的水坑效果,而是考虑当它们均匀地覆盖在表面或被吸收的效果。

在处理滞留和吸收的水之前,我们需要考虑哪些表面会在雨天被打湿。不难思索,只要是朝上且上方不存在的表面,或多或少都会沾到雨水。对于方向,我们可以使用视口空间法线与 OptiFine 提供的视口空间天顶坐标

uniform vec3 upPosition;

做点积,即 float up = dot(surfaceNormal, normalize(upPosition)) 。或者,我们还可以使用世界空间法线与 做点积,我们知道法线的模长总是为 1,而点积是将两向量的对应分量相乘相加,因此我们只需要世界空间法线的 分量即可。要想求得世界空间法线,一种办法是使用模型视口空间的逆矩阵:

uniform mat4 gbufferModelViewInverse; [... main ...] vec3 worldNormal = (gbufferModelViewInverse * vec4(surfaceNormal, 0.0)).xyz;

然而求得完整的世界空间法线非常不划算,需要一次 float4x4 * float4 或者 float3x3 * float3 ,我们只需要世界空间法线的 分量。来看看 的元素如何排列:

逆矩阵左上角的 块意义与原矩阵类似,每一列表示视口空间中的一个轴在世界空间中的朝向,而每一行则表示视口空间中每个轴的朝向在世界空间一个轴上的投影量。

要想求得世界空间法线的 分量,我们需要将视口空间的法线全部投影到世界空间的 轴上,也就是说我们只需要计算第二行的前三个分量,即:

float up = gbufferModelViewInverse[0].y * surfaceNormal.x + gbufferModelViewInverse[1].y * surfaceNormal.y + gbufferModelViewInverse[2].y * surfaceNormal.z;

只需要两次乘加和一次乘法就搞定,开销与 dot(surfaceNormal, normalize(upPosition)) 相比还少了一次 normalize()

求得了表面方向,我们接着来判定表面是否被遮挡。就目前来讲,我们是无法准确地知道表面的上方是否有物体遮挡的,因为每个顶点甚至都无法访问临近顶点的数据,更别说其他的方块了。不过我们确实有办法间接估计,还记得天空光照吗,Minecraft 使用 Flood Fill 算法从上至下蔓延天空光照,当路径上有 散射光照的方块 时,天空光照就需要从临近的竖列蔓延过来,从而导致光照等级减一, 越大型的天花板下,天空光照就会越弱 。这给我们提供了一个思路:使用天空光照等级来判断光照被遮挡的情况。

之前,我们将天空光照压缩到了 ,游戏中的天空光照一共 16 个等级,也就是 ,雨水不会完全竖直落下,我们可以使用最明亮的两个光照等级来进行过渡,也就是 14 ~ 15,对应到 lightmap.t 就是 0.933 ~ 1:

float openair = smoothstep(0.933, 1.0, lightmap.t);

将这两个参数相乘,我们就能得到表面对雨水的“暴露”程度:

up = max(up, 0.0); float exposed = up * openair;

当然,这种估计方法对玻璃这类不会散射天空光照的方块就无能为力了。无论如何,我们终于可以开始处理表面被打湿的效果了,不妨从不透水材料开始。

水是无色的透明液体,光线打在水膜上要么会直接反射掉,要么折射出下层材质与光照的交互结果,因此当材料表面聚成水膜时,最简单的办法就是额外计算一层菲涅尔,反射部分取水体的反射,而折射部分则取附着材料的出射光照 1 。据此,我们额外计算一套水膜的光照,注意,水膜只有反射部分,因此没有菲涅尔的光照(方块光照和基本亮度)不必计算:

[... Lighting ...] // 水体的菲涅尔无颜色分量差异,你也可以直接使用 f_schilck 的同名重构函数。 float f_schilck_mono(float f0, float cosTheta) { return mix(pow(1.0 - cosTheta, 5.0), 1.0, f0); } float f_schilck_mono(float f0, float cosTheta, float roughness) { return f0 + (max(1.0 - roughness, f0) - f0) * pow(1.0 - cosTheta, 5.0); } [... Final ...] // 表面只需要计算基本的菲涅尔即可 vec3 litDirect = calcLighting(...); vec3 litAmbient = calcLighting(...); float wet_smoothness = wetness * exposed; float wet_roughness = pow(1.0 - wet_smoothness, 2.0); const float water_f0 = 0.02; float fresnelWet = f_schilck_mono(water_f0, ndv); float fresnelWetR = f_schilck_mono(water_f0, ndv, wet_roughness); // 将 wet_smoothness 同时用作水膜菲涅尔强度的系数,确保过渡平滑 fresnelWet *= wet_smoothness; fresnelWetR *= wet_smoothness; vec3 litWetDirect = calcLighting(vec3(fresnelWet), litDirect, // 使用附着材料的出射光作为折射 getSpecular(surfaceNormal, halfwayVec, wet_smoothness)) * (sunBrightness * sunColor + moonBrightness * moonColor) * (lit * rainFactor); // 分别处理向量乘法和标量乘法 vec3 litWetAmbient = calcLighting(vec3(fresnelWetR), litAmbient, 1.0) * ambientColor * ( lightmap.t * AMBIENT_BRIGHTNESS * (sunBrightness + moonBrightness) * (albedo.a * rainFactor)); #ifdef TXAO float txao = remap(0.0, 1.0, TXAO_STRENGTH, 1.0, normalMap.b); // TXAO 也可以用 remap2() 了 litWetAmbient *= txao; #endif fragColor.rgb = litWetDirect // 将场景光照替换为水膜光照! + litWetAmbient // 环境光照也要记得替换! + litBlock + litBase;

[1] 也许你意识到了水膜反射掉的光线不再会照亮附着材料,因此到达材料表面的光强会轻微减弱,需要在入射方向也计算一次水膜菲涅尔来求得水膜折射到材料表面的实际光强,但这种影响微乎其微,只有当入射光角度大时才会稍显差异,然而这时候主导表面光强的是光照的角度而不是水膜的反射,因而目前为入射再单独计算一次菲涅尔是不划算的。不过在之后编写 PBR 光照时我们会完整考虑整个光路来不计成本地获得最好的光照。

当然,额外计算一次光照是很昂贵的,因此我们可以仅在雨天的裸露表面处理它们:

vec3 litDirect = calcLighting(...); vec3 litAmbient = calcLighting(...); vec3 litWetDirect; vec3 litWetAmbient; float wet_smoothness = [...]; if(wet_smoothness > 0.0) { float wet_roughness = [...]; float fresnelWet = [...]; [...] litWetDirect = calcLighting(...); litWetAmbient = calcLighting(...); } else { litWetDirect = litDirect; litWetAmbient = litAmbient; } litWetDirect *= [...]; litWetAmbient *= [...];

庞大的计算量(目前来说还不算太大)让分支的开销变得可以接受。

接下来轮到透水表面了,透水表面意味着材料能够吸水,材料吸水之后由于光路变化,折射出的光线会减少,表面会显得暗淡。因此透水材料的表面处理就很简单了:

[... Settings ...] #define POROSITY_DIFFUSE_DECAY 0.4 [... Final ...] diffuse *= POROSITY_DIFFUSE_DECAY;

最后,我们来综合考虑这两种情况,如果你还记得 LabPBR 格式就再好不过了,高光纹理 Blue 通道中的 表示了孔隙率 !我们将其重映射到 上就是:

int spec_blue = f2i8(material.b); float porosity = remapSaturate(0.0, 64.0, float(spec_blue)) * float(spec_blue <= 64); // 次表面散射材质的孔隙率始终为 0

孔隙率增大时,水膜的反射会减弱,表面的出射光也会变弱,因此我们可以改写之前的算法:

float wet_level = wetness * exposed; float diffuseDecay = remap2(0.0, 1.0, 1.0, POROSITY_DIFFUSE_DECAY, wet_level * porosity); vec3 diffuse = [...] * diffuseDecay; float wet_smoothness = wet_level * (1.0 - porosity);

当然,双层材质对我们目前这样简单的光照模型来说还是太大材小用了,你也可以尝试使用更加简单的最大光滑度来处理它。 习题 2

被雨雾遮挡的场景

雨滴会折射背后的景物,当雨水变多时,折射的光路变得不规则,最后就产生了雾蒙蒙的感觉。为了节省性能,Minecraft 会在摄像机附近生成雨水条带,而较远的地方则不会。雨水在第二轮几何缓冲中渲染,因此我们的画面上还看不到它们,不过没关系,让我们先把远景的雨雾补足。

雨水在空间中的分布可以看作是均匀的,因此我们可以直接将 rainStrength 用来计算雨雾浓度后加在之前我们写好的雾气中 visibility 公式的负指数上。

[... Settings ...] #define FOG_RAIN_DENSITY 0.015 // 密度不要太高! [... Final ...] float l = length(pos); // 我们现在需要独立的 L,雨雾的 ρ 是 FOG_RAIN_DENSITY float rhoL = l * FOG_BASE_DENSITY; [...] float rain = rainStrength * FOG_RAIN_DENSITY * l; float visibility = exp(-(fogDensity + rain));

负指数让我们不用担心溢出,而且均匀的雨滴分布也简化了积分。当然,雨雾只是很粗略的估计,也无法计算遮蔽,因此也可以使用 wetness 替代 rainStrength 来将其视为雨天空气湿度升高产生的水汽。

这一小节内容很简单,原本计划作为习题,但为了让下个小节的内容更加丰富,笔者决定将实现写在此处,以免有人不写作业不知道雨雾哪来的。

把雨天的光照系数稍微调高一些,看起来还算不错!

Environment wet surface

动图左侧是水膜的光滑度 wet_smoothness ,右下角的柱状图和之前一样,分别是降雨强度 rainStrength 和湿度 wetness

雨天效果的添加就到此告一段落,你也能明显地看出,这远不是雨天效果的完全体,等之后的章节中我们完成了反射和大气散射后,画面的质量还会得到飞跃。接下来,我们将会利用其他大气参数来调整编写好的雨天效果。

响应群系的大气

大气会因为当地气候的不同而呈现不同的样貌,Minecraft 的群系 (Biome)也隐含了当地的气候特征,当我们身处不同的群系时,相关参数也会发生变化。

本小节中,我们会通过调整上个小节的雨天效果,并手动设计几个有特色的生物群系来初步探索这些参数。

我们的第一个小任务是根据降水类型来选择表面效果。降水类型为降雪的群系会覆盖雪片,因此我们暂且将降水类型为雪天和无的群系一视同仁。

大气参数

Minecraft 通过多个密度函数来决定当地的生物群系,OptiFine 除了提供所在群系的 ID、分类和降水类型外,还提供了群系的降水值和温度作为可用参数。

降水类型决定了降水的形式,会从无、降雨和降雪中选择其一;降水值用于决定当地植被的颜色,与降水类型无关,也不能用于判定当地是否降水。可以在 生物群系#气候列表 - 中文 Minecraft Wiki 中查找每个群系的降水值。

OptiFine 提供这些参数用于我们自己构造自定义统一变量:

# 群系 ID variable.float.biome # 群系类型 variable.float.biome_category # 群系降水类型 variable.float.biome_precipitation # 温度 variable.float.temperature # 降水值 variable.float.rainfall

本小节我们会利用群系降水类型和降水值来调整上一个小节中我们完成的降水效果。

让我们从群系降水类型开始,降水类型 biome_precipitation 会在 PPT_NONEPPT_RAINPPT_SNOW 间选择,依次为值 0,1,2。我们只关系降雨与否,因此我们可以利用在配置文件中可用的表达式 if(cond, val, [cond2, val2, ...], val_else) 来进行判定,它与 C-like 的用法不同,我们直接在函数中依次排列条件和满足条件的取值。

和之前在发光实体处理中使用的 cam_isGlowing 类似,我们以 biome_ 前缀表示群系变量:

variable.float.biome_isRain = if(biome_precipitation == PPT_RAIN, 1.0, 0.0)

群系切换时这些参数都是突变的,OptiFine 提供了在配置文件中平滑插值它们的函数 smooth([id], val, [fadeInTime, [fadeOutTime]]) 。它接受唯一 ID,要平滑的变量,淡入时间和淡出时间来在 CPU 上平滑地处理各种变量。ID、淡入淡出时间都是可选的,如果未设置会自动分配 ID,过渡时间类似 wetness 的半衰期,淡入时间(值升高)默认为 1 刻,淡出时间(值降低)默认为淡入时间。

我们之前准备好的中间变量 biome_isRain 就用来进行插值,插值完毕的变量 biome_smoothedRain 就用来作为统一变量传入光影中:

uniform.float.biome_smoothedRain = smooth(1, biome_isRain, 5, 12)
uniform float biome_smoothedRain;

进入降雨群系时每 5 刻值会增长一半,离开降雨群系时的半衰期则是稍慢的 12 刻,我们将它乘入 wet_level 就能让地面在进入降雨群系时快速打湿,而在离开时干燥得稍慢。

另一个变量 rainfall 我们也同样利用 smooth() 在不同群系间平滑:

uniform.float.biome_rainfall = smooth(2, rainfall, 2, 5) * 2.5
uniform float biome_rainfall;

平原的降水值仅为 0.4,我们之前的效果都在平原群系处理,因此我们会以此降水值为基准放缩到 1.0(如果你调整了参数并且处于其他群系,可以在 生物群系#气候列表 - 中文 Minecraft Wiki 中查找你当地群系的降水值)。对于大于 1 的部分,除了影响雨雾浓度,孔隙率较高的材料也会因为饱和而在表面留下水坑,因此 diffuseDecay 中的降水值需要钳制,而 wet_smoothness 中的降水值需要与孔隙率一同影响光滑度。

[... 透水材料吸收部分 ...] float wet_level = wetness * exposed; float diffuseDecay = remap2(0.0, 1.0, 1.0, POROSITY_DIFFUSE_DECAY, wet_level * porosity * min(biome_rainfall, 1.0)); // 钳制漫射衰减 [... 疏水材料反射部分 ...] float wet_smoothness = wet_level * min(1.0 - porosity + max(biome_rainfall - 1.0, 0.0), 1.0); // 高降水区域增加额外光滑度,但不超过 1 [... 雨雾部分 ...] float rain = rainStrength * FOG_RAIN_DENSITY * l * biome_rainfall;

你可以在 附录 3 - 自定义统一变量 - 参数 中查看各种参数的定义。

限定群系效果

JE 1.17 开始,Mojang 逐渐放开了手脚开始改动主世界生物群系(虽然也不能说放得很开吧……),并在 Minecraft Live 2024 之后进行了许多轮小型更新,得以让更多有特色的生物群系面世。

本次,我们将聚焦于 JE 1.19 加入的 深暗之域JE 1.21.2 加入的 苍白之园 (刚好我们教程所使用的 JE 1.21.4 是苍白之园正式移出实验性玩法的第一个版本)两个风格迥异却又非常相似 (吓人这一块) 的群系,以及每个新手都曾向往过的世外桃(菇)园 蘑菇岛 三个群系,为它们添加独属于当地的大气效果。

迷雾笼罩的园林

让我们从比较常见的苍白之园开始,如果你没有玩过新版本,那么苍白之园描述起来大概是这样:
苍白之园会随机替代黑森林群系,这里气候湿热,长满遮天蔽日的苍白橡木,没有任何动物在此生成。白天这里被树木笼罩,光照已经极其有限,一旦到了夜晚,埋藏于树干中的嘎枝之心便蠢蠢欲动,在林中召唤许多嘎枝,团团包围并吞噬误入其中的旅者。

这里有一些笔者以前使用 iteration 系列光影摄制的图片做为参考:

苍白之园

我们就以阴沉的环境作为这个群系的核心体验。让我们先搜索一个苍白园林群系,点击聊天栏返回的坐标进行传送,以便随时查看修改效果:

/locate biome minecraft:pale_garden
找到的苍白之园

呃,我也没想到这个种子最近的苍白之园小成这样 ,还一副吊样

Whatever,让我们开始效果的编写吧,希望你们的苍白之园能大一些。进入苍白之园就会发现环境阴沉了下去,天空光照变得灰白一片。和雨天类似,我们会在这个群系干两件事:增加雾气浓度并削弱光照,只不过程度没有雨天那么强烈。让我们先检查当前是否在苍白之园中:

variable.float.biome_is_pale_garden = if(biome == BIOME_PALE_GARDEN, 1.0, 0.0) uniform.float.biome_inside_pale_garden = smooth(3, biome_is_pale_garden, 2000, 15)
uniform float biome_inside_pale_garden;

我们使用 BIOME_<命名空间ID> 来取得特定群系对应的值。我们使用了一个 极其 漫长的过渡(半衰期 100 秒)来淡入群系,以便营造在苍白之园中停留越久,就会被浓雾吞噬得越多的体验,并在离开苍白之园时快速衰减,从而营造出成功逃离的解脱感。

然后,我们将它分别用于设置雾气和日照:

[... Settings ...] #define PALE_GARDEN_BRIGHTNESS 0.3 #define PALE_GARDEN_PCF_FACTOR 4.0 #define PALE_GARDEN_FOG_FACTOR 50.0 [... Final ...] [... PCF 采样循环 ...] vec2 steps = vec2(i,j) * float(PCF_RADIUS) / float(PCF_SAMPLES) * 0.5 * remap2Saturate(0.0, 1.0, 1.0, PCF_RAIN_FACTOR, rainStrength) * remap2(0.0, 1.0, 1.0, PALE_GARDEN_PCF_FACTOR, biome_inside_pale_garden); [... 光照部分 ...] float biomeFactor = remap2(0.0, 1.0, 1.0, PALE_GARDEN_BRIGHTNESS, biome_inside_pale_garden); vec3 litWetDirect = calcLighting(vec3(fresnelWet), litDirect, getSpecular(surfaceNormal, halfwayVec, wet_smoothness)) * (sunBrightness * sunColor + moonBrightness * moonColor) * (lit * rainFactor * biomeFactor); vec3 litWetAmbient = calcLighting(vec3(fresnelWetR), litAmbient, 1.0) * ambientColor * ( lightmap.t * AMBIENT_BRIGHTNESS * (sunBrightness + moonBrightness) * (albedo.a * rainFactor * biomeFactor)); [... 雾气部分 ...] rhoL = l * FOG_BASE_DENSITY * remap2(0.0, 1.0, 1.0, PALE_GARDEN_FOG_FACTOR, biome_inside_pale_garden);

我们给苍白之园取了一些比较极端的参数,来获得 随着时间被群系吞没 的体验,你可以根据自己的喜好调整它们。这样我们就完成了苍白之园的过渡效果,将效果加快近百倍后看起来就像这样:

苍白之园的过渡

右下角信息从左至右分别为 biome_inside_pale_gardenbiome_is_pale_garden

暗无天日的地下遗迹

编写完地上的迷雾,让我们放眼地下。深暗之域生成在主世界的地下深处,这里不会自然生成任何生物,只有进入这里的生物不小心发出声响时,才会藉由幽匿尖啸体召唤出的监守者。远古城市会生成在深暗之域中,为这个群系更添一丝静谧。

这个群系始终生成在地下,这里的雾气浓度本来就会很高,但是深色雾相比较亮色雾来说更不明显,因此仍然需要一个乘数。既然这里主打的就是黑暗,我们可以完全禁用阳光,就算它们生成在露天矿洞的深处也没法接受到哪怕一丝光线。此外,这里也不应该存在天空光照,因此在没有自发光材质的情况下,整个群系基本上就是完全的黑暗,方块光照也因为弥漫的黑雾而变得微弱,让探索这里的人更加趋光。最后,我们可以增强一点基础光照来平衡观感,营造出在阴暗诡异、阳光无法穿透的群系中探索的体验。

让我们先寻找一个深暗之域群系:

/locate biome minecraft:deep_dark

或者你也可以寻找远古城市结构:

/locate structure minecraft:ancient_city
找到的深暗之域

看起来与想象中的差别有些大,因为深暗之域是没有自己的天空颜色和雾色的,我们就直接使用纯黑色了。现在来准备群系过渡的参数:

variable.float.biome_is_deep_dark = if(biome == BIOME_DEEP_DARK, 1.0, 0.0) uniform.float.biome_inside_deep_dark = smooth(4, biome_is_deep_dark, 5, 200)
uniform float biome_inside_deep_dark;

与苍白之园相反,我们设置在进入群系时迅速淡入,而在离开群系则缓慢淡出,从而营造出踏入深暗之域的瞬间就被黑暗笼罩,而离开时黑暗仍然残留的体验。

在完全切换到深暗之域后,我们不再需要计算阴影,光照归零,因此我们可以直接使用分支进行判定跳过 PCF 来节省性能。

#define DEEP_DARK_BASELIGHT_FACTOR 3.0 #define DEEP_DARK_BLOCKLIGHT_FACTOR 0.5 #define DEEP_DARK_FOG_FACTOR 5.0
[... 阴影部分 ...] float lit = 0.0; bool draw_shadow = lightmap.t != 0.0 && biome_inside_deep_dark < .99; if(draw_shadow) { lit = max(dot(lightDir, surfaceNormal), 0.0); lit *= calcShadow(shadowtex0, worldPos, lightDir, vertexNormal); } float deep_dark_colorFactor = 1.0 - biome_inside_deep_dark; [... 环境光照部分 ...] ambientColor = [...] * deep_dark_colorFactor; [... 方块光照部分 ...] litBlock = [...] * remap2(0.0, 1.0, 1.0, DEEP_DARK_BLOCKLIGHT_FACTOR, biome_inside_deep_dark); [... 基础光照部分 ...] litBase = [...] * remap2(0.0, 1.0, 1.0, DEEP_DARK_BASELIGHT_FACTOR, biome_inside_deep_dark); [... 雾气部分 ...] float rhoL = l * FOG_BASE_DENSITY * remap2(0.0, 1.0, 1.0, PALE_GARDEN_FOG_FACTOR, biome_inside_pale_garden) * remap2(0.0, 1.0, 1.0, DEEP_DARK_FOG_FACTOR, pow(biome_inside_deep_dark, 8)); [...]; vec3 fog = skyColorG + fogColorG * pow(ambientFogFactor, 4); fog *= deep_dark_colorFactor;

我们给雾气的浓度乘数中设置了一个 8 次方的指数,从而平衡不同亮度造成的非线性观感,以免在离开深暗之域时由于雾气遮蔽导致的视野不清。

布满孢子的海洋孤岛

最后,让我们把目光投向这三个群系中资历最老,在主世界中最稀有的群系:蘑菇岛。蘑菇岛上布满菌丝和巨型蘑菇,还会自然生成哞菇。这里不会生成任何敌对生物,不缺吃喝,而且通常面积较小,是作为藏身处的不二之选。

/locate biome minecraft:mushroom_fields
找到的蘑菇岛(大陆?)

笔者这个种子实在是过于极品,不仅之前的苍白园林只有几个区块,从那附近的深暗之域寻到的最近的蘑菇岛极小,甚至完全没有露出海面的部分:-1507526119660278460,最后实在无奈只能用 Chunkbase 查询其他蘑菇岛。

一个遍地真菌的群系,我们第一时间想到的就是空气中肯定会布满孢子。蘑菇孢子颜色比较杂乱,我们就选择一个和菌丝相近的偏紫色( )来处理它们。

variable.float.biome_is_mushroom_fields = if(biome == BIOME_MUSHROOM_FIELDS, 1.0, 0.0) uniform.float.biome_inside_mushroom_fields = smooth(5, biome_is_mushroom_fields, 10, 20)
uniform float biome_inside_mushroom_fields;
const vec3 sporeColor = vec3(0.84, 0.70, 0.93);

和深暗之域类似,我们将进入蘑菇岛时的过渡设置得稍快,来模拟空气中持续蔓延的蘑菇孢子的情况,在退出时则消散得较慢,来模拟蘑菇岛漫开的孢子随时间消散的感觉。我们设置孢子在空气中的浓度比例来混合雾色和修改雾气浓度:

#define MUSHROOM_FIELDS_SPORE_DENSITY 0.001 #define MUSHROOM_FIELDS_SPORE_COLOR 0.6
float spore_rainWash = 1.0 - max(rainStrength, wetness); float spore = biome_inside_mushroom_fields * MUSHROOM_FIELDS_SPORE_DENSITY * spore_rainWash * l; float visibility = exp(-(fogDensity + rain + spore)) [...]; vec3 fog = [...]; fog = mix(fog, sporeColor, MUSHROOM_FIELDS_SPORE_COLOR * biome_inside_mushroom_fields * spore_rainWash);

这里有一个 小巧思 ,雨天孢子会被冲刷掉,因此 wetnessrainStrength 升高时孢子的雾气浓度反而会降低,我们取两者中的最大值来影响空气中孢子的浓度,以便雨天快速冲刷掉孢子,晴天则缓慢蔓延,并且大雨持续得越久,晴天孢子就会蔓延得越慢。

当然,你也可以不让孢子在整个 Y 轴上均匀蔓延,参考之前的雾气编写方法,为孢子编写衰减函数并积分。

直接绘制天顶 · 渲染阶段

我们之前的天空都直接利用了 skybasic 中由游戏自动生成的穹顶,然而这个天空不能说很好看吧,至少也是难绷的断层拉满,特别是地平线附近:

天边的断层

原版只提供了一个上半球为天空颜色、下半球为雾色的圆球来绘制天空,实在太过简陋。你肯定还记得(应该吧……?) skybasic 负责穹顶和星星的绘制,因此我们只需要在判定到绘制穹顶时将它们全部丢弃就行了。

OptiFine 提供了许多内置宏,其中就包括更详细的渲染阶段,它们可以用来细致地判定当前程序所处理的内容。在之前我们已经初步使用过它们了,现在让我们再次回顾:

#define MC_RENDER_STAGE_NONE <const> // 未定义 #define MC_RENDER_STAGE_SKY <const> // 天空 #define MC_RENDER_STAGE_SUNSET <const> // 日出日落时的红晕 #define MC_RENDER_STAGE_CUSTOM_SKY <const> // 自定义天空 #define MC_RENDER_STAGE_SUN <const> // 太阳 #define MC_RENDER_STAGE_MOON <const> // 月亮 #define MC_RENDER_STAGE_STARS <const> // 星星 #define MC_RENDER_STAGE_VOID <const> // 虚空 #define MC_RENDER_STAGE_TERRAIN_SOLID <const> // 固体地形 #define MC_RENDER_STAGE_TERRAIN_CUTOUT_MIPPED <const> // MipMap 裁切地形 #define MC_RENDER_STAGE_TERRAIN_CUTOUT <const> // 裁切地形 #define MC_RENDER_STAGE_ENTITIES <const> // 实体 #define MC_RENDER_STAGE_BLOCK_ENTITIES <const> // 方块实体 #define MC_RENDER_STAGE_DESTROY <const> // 挖掘裂纹覆盖 #define MC_RENDER_STAGE_OUTLINE <const> // 方块选择框 #define MC_RENDER_STAGE_DEBUG <const> // 调试渲染 #define MC_RENDER_STAGE_HAND_SOLID <const> // 固体手持物品 #define MC_RENDER_STAGE_TERRAIN_TRANSLUCENT <const> // 半透明地形 #define MC_RENDER_STAGE_TRIPWIRE <const> // 绊线 #define MC_RENDER_STAGE_PARTICLES <const> // 粒子 #define MC_RENDER_STAGE_CLOUDS <const> // 云 #define MC_RENDER_STAGE_RAIN_SNOW <const> // 雨雪 #define MC_RENDER_STAGE_WORLD_BORDER <const> // 世界边界 #define MC_RENDER_STAGE_HAND_TRANSLUCENT <const> // 半透明手持物品

每个宏的数值都是特定的常量,相当于以 MC_RENDER_STAGE_<...> 作为 ID,通过当前程序渲染阶段的统一变量 renderStage 来检查当前阶段:

uniform int renderStage;
if(renderStage == MC_RENDER_STAGE_<...>) { ... }

我们之前直接引用了 gbuffers_color_only 来处理 skybasic ,因此可以直接在 Color Only 判定到渲染阶段为天空时进行剔除:

// 片元着色器部分 void main() { if( renderStage == MC_RENDER_STAGE_SKY || renderStage == MC_RENDER_STAGE_SUNSET) { discard; } [...]; }

这样,原版难看的穹顶就清理掉了,我们这里还额外清理了一个 SUNSET ,它负责在日出和日落时在天边产生弧形的雾色高亮,我们会手动设置雾气在光源方向附近的高亮色,因此就不需要它了。

清理后的天空

当然,你可能会寻思怎么天顶这么白,而地平线附近反而要更蓝,这是因为 0 号缓冲区的清除颜色是动态的雾色,原本几乎不会显露,将天空剔除后就彻底穿帮了。

虽然我们可以使用

const vec4 <缓冲区索引>ClearColor = vec4(r,g,b,a);

来设置清理颜色,但是由于这和配置文件一样是控制 GL 上下文的,因此只能由数字标量构建。

为此,我们可以使用在第一轮几何缓冲之前的延迟处理阶段 Prepare 来绘制天空。和 Composite、Deferred 类似,Prepare 也在铺屏四边形上处理场景,因此我们只需要将之前 final.vsh 的内容拷贝到 prepare.vsh ,然后在 prepare.fsh 中输出天空颜色就好:

#version 330 core #include "/libs/Attributes.glsl" out vec2 uv; void main() { gl_Position = vec4(vaPosition.xy * 2.0 - 1.0, 0.0, 1.0); uv = vaUV0; }
#version 330 core #define PREPARE_SHADER // 记得在 Uniforms.glsl 里添加相应判定! #include "/libs/Uniforms.glsl" // 包含 skyColor 用于计算 skyColorG #include "/libs/Settings.glsl" // 包含 GAMMA 用于线性映射到 skyColorG #include "/libs/Utilities.glsl" // 包含 vpow() 函数用于计算 skyColorG #include "/libs/Atmosphere.glsl" // 包含 skyColorG /* DRAWBUFFERS:0 */ layout(location = 0) out vec4 fragColor; void main() { fragColor = vec4(skyColorG, 1.0); }

现在看起来就比之前好多了:

修正后的天空

现在我们终于可以将雾气换回纯正的雾色,然后将太阳方向的增亮改为阳光的颜色和亮度:

[... Final ...] vec3 sunDir = normalize(sunPosition); vec3 moonDir = normalize(moonPosition); [...] // 这些参数合并也可用于 litWetDirect vec3 sunLight = sunBrightness * sunColor; vec3 moonLight = moonBrightness * moonColor; float lightFactor = rainFactor * biomeFactor; [...] // 修改的环境光照,现在只根据是否朝上选择天空色或雾色 vec3 ambientColor = deep_dark_colorFactor * mix(fogColorG, skyColorG, pow(up, 2)); [...] float sunLumiFog = pow(max(dot(sunDir, -viewportDir), 0.0), 4); float moonLumiFog = pow(max(dot(moonDir, -viewportDir), 0.0), 4); vec3 fog = deep_dark_colorFactor * (fogColorG + (sunLight * sunLumiFog + moonLight * moonLumiFog) * lightFactor); // ambientFogFactor 已经没用了,可以直接删除 // 也可以写成 sunLight * sunLumiFog + moonLight * moonLumiFog + fogColorG // 以便编译为 MAD 节省开销

现在天空看起来也终于不那么廉价了:

新的雾色效果

目前我们的着色器已经越来越复杂了,因此也要记得随时整理变量和函数,将相似的功能实现放在一起、通用的数据准备放在程序开头,然后将同类型的功能存放在一个头中,以免维护地狱!

🚩检查点 由于本节内容过多,编译器已经报告 HTML 编译文件超过 1MB,因此我们拆分了接下来的内容。习题见 Part 2。

10 April 2026