MineGraph Docs Help

进阶延迟处理 · 环境 Part 2

🎈中继上文 让我们继续之前的内容。

药水效果

除了群系和大气外,Minecraft 中还有几种会影响环境的药水效果:失明、夜视和黑暗。在原版游戏中,它们很简单地影响能见度和光照亮度。本小节我们会让光影也响应这些效果。

OptiFine 提供了这些效果对应的统一变量:

uniform float blindness; uniform float nightVision; uniform float darknessFactor; uniform float darknessLightFactor;

失明

原版的失明效果只是将雾色设置为纯黑,然后将雾气开始距离设置在几米开外。我们不会完全还原它们,而是思考一些更有创造性的方案。

OptiFine 虽然提供了浮点的失明变量,但实际也是非 0 即 1 的量。因此我们仍然需要手动进行平滑:

uniform.float.effect_blindness = smooth(6, blindness, 20, 8)
uniform float effect_blindness;

不知道你还有没有留着我们在第一章开头时用来获取灰度和第二章中用来扩散描边的方块模糊函数 (没想到吧,还有 Callback):

float getLuminance(vec3 color) { const vec3 brightnessWeight = vec3(0.299, 0.587, 0.114); return dot(color, brightnessWeight); } vec4 boxBlur_D_C(sampler2D tex, vec2 uv, vec2 d, float r) { vec4 c = vec4(0.0); float count = r+.5; for(float i = -r + .5; i <= r; i += 2.0) { c += texture(tex, uv + d*i); } return (c + texture(tex, uv + d*r)*.5) / count; } // _D 表示每 Pass 模糊一个方向,_C 表示输出颜色。

我们假设失明之后色感降低,并且视野也会变得模糊,此外,还可以额外降低一些画面亮度。和之前绘制描边类似,我们会使用双 Pass 模糊来加速。

目前我们所有的延迟处理都在 Final 中完成,而这些后处理效果都需要在完成光照和雾气处理、进行描边之前的画面上进行,因此,我们会将目前的 Final 程序中光照和雾气部分全部转移到 Deferred 中 1 ,仅在 Final 中保留描边叠加(当然,记得采样一次 Colortex0 作为基底)。另外,我们还需要新的 Composite2 和 Composite3,它们都用来模糊 Deferred 输出的主画面(也就是 Colortex0),只是方向不一样。

[1] 至于为什么不选择 Composite 阶段,是因为之后的半透明处理我们会使用更加简略的向前渲染,如果渲染完成之后又通过 Composite 程序再次处理半透明的光照和雾气,就会导致 Overdraw(过度绘制)。

#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; }
#include "/programs/std_deferred.vsh"
[... Deferred ...] void main() { [... 光照,雾气 ...] } [... Final ...] void main() { vec3 color = texture(colortex0, uv).rgb; // 基底颜色 [... 发光描边 ...] }
[... Settings ...] #define BLINDNESS_BLUR_RADIUS 10.0 [... composite<2|3>.fsh 共用 ...] #version 330 core #define COMPOSITE_SHADER #include "/libs/Uniforms.glsl" #include "/libs/Settings.glsl" #include "/libs/Utilities.glsl" in vec2 uv; /* DRAWBUFFERS:0 */ layout(location = 0) out vec4 fragColor; void main() { [... composite2.fsh 专用...] vec2 d = vec2(pixelSize.x, 0.0); [... composite3.fsh 专用...] vec2 d = vec2(0.0, pixelSize.y); [... 共用部分继续 ...] float r = BLINDNESS_BLUR_RADIUS * effect_blindness; if(effect_blindness > 0.0) { fragColor = boxBlur_D_C(colortex0, uv, d, r); } else { fragColor = texture(colortex0, uv); } }

我们将模糊放在 Composite 而不是 Deferred 阶段,因为 Deferred 在第二轮几何缓冲之前,如果使用 Deferred 的话,半透明就不能正确模糊了。

你会发现在过渡时画面坐标和亮度会产生轻微抖动,这是由于采样方法和模糊除法导致的。我们将这视为一种对性能的 妥协 ,当然,如果不喜欢,你也可以使用我们在第一章中使用的,更通用的点上精确采样和精确除法累积函数,或者设置一个宏在两种模糊函数间切换:

vec4 boxBlurAccurate_D_C(sampler2D tex, vec2 uv, vec2 d, int r) { vec4 c = vec4(0.0); int count = 0; for(int i = -r; i <= r; ++i) { vec2 uv_displaced = uv + d*float(i); if(uv_OutBound(uv_displaced)) { continue; } c += texture(tex, uv_displaced); ++count; } return c / float(count); }

模糊完毕之后,我们在 Final 中继续处理灰度和亮度:

[... Settings ...] #define BLINDNESS_GRAY_LEVEL 0.8 // 混合灰度的最大量 #define BLINDNESS_BRIGHTNESS 0.3 [... Final ...] void main() { vec3 color = texture(colortex0, uv).rgb; float luminance = getLuminance(color); color = mix(color, vec3(luminance), effect_blindness * BLINDNESS_GRAY_LEVEL); color *= remap2(0.0, 1.0, 1.0, BLINDNESS_BRIGHTNESS, effect_blindness); }

处理完毕后,你会发现一些诡异的现象,比如转头的时候光照就抽风了。这是因为 OptiFine 的光源位置在失明、陷入细雪等情况下不会主动更新,很诡异对吧。不过没关系,我们可以自己推导光源的方向。

还有另外一些问题是,失明时,环境颜色会直接切换到纯黑,太阳也会突然消失。因此环境光照仍然是突变的,这些我们就暂时不处理了。

手动推导的光源方向

我们已知了太阳的南北倾角 sunPathRotation 和世界时间 worldTime 。

假设无南北倾角,当 时,太阳在正天顶上,归一化之后的世界时间为 ,以世界的 XY 为二维平面建立 坐标系,此时太阳的方向为 ,随着时间增长,太阳绕逆时针转动。

因此当 ,太阳弧度角 ,它们的关系式可以初步推定为

太阳弧度角与世界时间不是严格的线性对应,你可以从 太阳还未落坡,而 太阳早已升起看出来。我们可以使用三角函数来进行插值,这个插值函数我们记为 ,让值更倾向白天 1 。目前我们已经精确知道的值是 (正午, )和 (午夜, ),太阳分别在天顶和地底。

[1] 我们会取 的单调周期(半个周期)用于插值, 的第一个四分单调周期(正午至日落)小于对齐单调周期的 ( 和 时直线式 即为对齐,这种情况下 时也有 ),日落时太阳仍然靠近天顶方向,第三个四分单调周期(午夜至日出)大于对齐单调周期的 ,日出时太阳更早之前就靠近了天顶方向。 具有对称单调性,比 更方便。

如果 时我们插值完的 “临时太阳角” , 时 ,即 , 在正午和午夜时就能精确地落在 上,且日间的周期也会较长。

据此我们偏移 的值,即 “临时归一化时间” ,我们现在的任务是将线性的 映射到经过三角函数插值的 上。

接着,通过 函数插值 ,并且插值之后函数的单调性不改变。我们的 是分段函数,在 上单调递增,而 函数则在 上单调递减。因此,我们需要将 映射到 上,则与 单调周期对齐的归一化时间

将临时归一化时间代入插值后的时间表达式,就可以得到与 单调性一致,值域为 的

再次将它映射到 就是很简单的 ,则映射完的 “时间角”

这样,我们就得到了与 对齐的 。接下来我们需要参照这个原始的时间角来求值域在 上的太阳角 ,它与 的差异大约只有 的三分之一,我们可以通过形如 的方式来在不改变 与 交点 1 的情况下应用 的更改,即:

[1] 在我们的公式中 ,这样就不会改变 或 时太阳在正天顶或地底的情况。

最后,我们将 “归一化” 太阳角放缩到 就可以了:

需要记住, 时 “临时太阳角” ,此时实际的太阳角 ,因此 ,则太阳方向

已经松了口气了吗?别急哦,别忘了还有我们的太阳倾角 。不过它比较简单,思考一下,太阳倾角让太阳绕世界的东西轴 ,也就是 轴进行旋转,因此它实际上旋转的是我们之前考虑到的 XY 平面,它的旋转轨迹实际上只与 YZ 轴有关。

当 时,太阳位于北半球,也就是 , 时,太阳位于南半球。如果我们建立 平面坐标系,则太阳方向轨迹

我们综合考虑 和 就可以得到太阳倾角:

这就是许多光影都在使用的太阳位置计算函数:

[... Settings ...] #define SUN_DISTANCE 2000 [... Ultilities ...] vec3 getSunPosition() { const float Rs = sunPathRotation * 180.0 / Pi; const vec2 RsData = vec2(cos(Rs), -sin(Rs)); float tn = fract(worldTime / 24000.0 - .25); tn = 2.0/3.0*Pi * (2*t-0.5*cos(t*Pi)+0.5); vec2 RtData = vec2(-sin(tn), cos(tn)); vec3 sunPos = vec3(RtData.x, RtData.y * RsData) * SUN_DISTANCE; return (mat4(gbufferModelView) * vec4(sunPos, 1.0)).xyz; }

我们求到了太阳的 “位置” 而不是方向,差不多就是将它视为了一个点光源。这是 Complementary 光影中的实现方案,当然,你也可以据此编写将太阳视为平行光的 getSunDirection() 和其他配套获取月亮位置/方向及投影源位置/方向的函数,我们今后会视场合直接使用它们。 习题 4

vec3 sunDir = normalize(getSunDirection()); vec3 moonDir = normalize(getMoonDirection()); vec3 lightDir = normalize(getShadowLightDirection(sunDir, moonDir));

黑暗

黑暗 是在 JE 1.19 随深暗之域一起加入的效果,当附近 40 格有可生成监守者的幽匿尖啸体激活,或附近 20 格有监守者时,玩家会被给予黑暗效果。黑暗效果会在玩家周围生成黑色浓雾,并让光照在不可见与勉强可见间缓慢闪烁。听起来有点过于简单了,如果是单纯的降低光照强度,这个效果就太过平庸了。

黑暗效果有两个参数,一个是指示黑暗效果强度的 darknessFactor ,另一个是指示光照周期性闪烁的 darknessLightFactor 。我们会用前者来调整光照和黑雾浓度,而后者则用来额外绘制画面边缘的晕影 。当然,我们现在要强制禁用原版晕影了:

vignette = false # 可以在配置文件中强制禁用原版晕影

光照和雾气的部分还是仿效之前其他处理那样进行:

[... Settings ...] #define DARKNESS_BRIGHTNESS 0.3 #define DARKNESS_FOG_FACTOR 1.2 [... Deferred ...] [... 光照部分 ...] float darkness = remap2(1.0, 0.0, DARKNESS_BRIGHTNESS, 1.0, darknessFactor); fragColor.rgb = darkness * (litWetDirect + litWetAmbient + litBlock) + litBase; [... 雾气部分 ...] float rhoL = [...] * remap2(0.0, 1.0, 1.0, DARKNESS_FOG_FACTOR, darknessFactor);

而晕影这种最后处理的效果我们会放在 Final 中的描边处理之前。

[... Settings ...] #define VIGNETTE 0.0 // 基本晕影强度,不太喜欢就设置为 0 #define VIGNETTE_START 0.4 #define VIGNETTE_END 1.3 // 屏幕边缘为 sqrt(2) ≈ 1.414 #define DARKNESS_VIGNETTE 2.0 [... Final ...] float vignette = smoothstep(VIGNETTE_START, VIGNETTE_END, length(uv_ndc)); float vignetteStrength = VIGNETTE + DARKNESS_VIGNETTE * darknessLightFactor; color *= 1.0 - vignette * saturate(vignetteStrength);

指示黑暗脉动的 darknessLightFactor 峰值在 0.5 附近,因此 DRAKNESS_NEGCOL_MIX 值最好不要超过 2。也可以根据自己的喜好选择 darknessFactor 和 darknessLightFactor。

夜视

扩展阅读 · 反色晕影与过渡

HSV 空间

OKLch 空间

高动态范围与压缩

色彩映射

简单自动曝光

习题

  1. (选做)尝试将简单高度雾的竖直方向改写为线性衰减的积分形式。我们需要进行分段积分:

    • 如果片元和摄像机均位于 ,直接使用 remap(320.0, 63.0, altitude) 的积分式 ;

    • 如果片元和摄像机均位于 ,则使用常量积分 ;

    • 如果片元和摄像机有一个位于 :

      • 若另一个位于 ,则使用 , 和 在两个坐标之间选择;

      • 若另一个位于 ,则使用 ;

    • 若片元和摄像机均位于 则没有雾气。

    最后,将它们乘入片元距离,再像简单水平雾那样进行幂次处理即可。

  2. 为雨水额外计算一次光照是很不划算的,特别是对目前我们这种简单的光照模型来说,可以使用一种更加精简的模型。

    • 尝试将天气小节的水膜 F0 与表面 F0 取最大值并根据水膜光滑度混合,然后取表面和水膜光滑度的最大值用于反射和菲涅尔,这样就只需在直接光照和环境光照各执行一次 calcLighting()。

    • 可以设置一个开关来在两种模型之间切换,双层材质在 PBR 和反射中会有更好的效果。

  3. (主观题)将阳光亮度与气温变量 temperature 挂钩,气温可以降至零下,一些模组的气温可能会很极端,因此记得做钳制。

  4. 参照 getSunPosition() 编写 getSunDirection() 以及配套的 get<Moon|ShadowLight><Position|Direction>() ,共 6 个获取光源的函数,用于替换 sunPosition 。月亮与太阳的轨迹相差半个周期。

31 May 2026