公司动态
Godot 4 复古渲染:从顶点抖动到色彩量化,复刻 PSX 美学
1. 项目概述为什么我们要重温PSX风格如果你和我一样是看着《最终幻想7》、《生化危机》、《寂静岭》这些游戏长大的那么PSXPlayStation 1那种独特的视觉风格一定在你记忆里留下了深刻的烙印。那种低分辨率、抖动顶点、有限的色彩深度和独特的纹理扭曲在今天看来是“技术限制”但在当时却塑造了一种无可替代的、充满想象力的美学。如今我们有了功能强大的现代引擎反而可以主动选择去复现这种风格这本身就是一件充满情怀和创造力的事情。Godot引擎以其轻量、开源和高度灵活的特性成为了实现这类风格化项目的绝佳平台。它不像一些商业引擎那样有沉重的历史包袱你可以从零开始完全掌控渲染管线的每一个细节这正是复刻特定复古风格所需要的。这个项目教程就是要带你一步步在Godot 4中构建一个从视觉到交互都充满PSX时代韵味的游戏原型。我们不仅仅是调几个后处理滤镜而是要深入到顶点着色器、片段着色器、光照模型甚至音频处理层面去理解和实现那些构成PSX美学的核心要素。无论你是想制作一款怀旧风格的独立游戏还是单纯对图形编程和风格化渲染感兴趣这个教程都将提供一套完整、可落地的技术方案。我们会从最基础的场景搭建开始逐步添加顶点抖动、仿PSX分辨率、色彩量化、抖动阴影等效果最终整合成一个可玩的、氛围感十足的小场景。过程中我会分享我踩过的坑和优化技巧让你不仅能做出效果还能理解背后的原理。2. 核心美学解析与Godot实现思路PSX的视觉风格并非单一特效而是多种硬件限制叠加后产生的“化学反应”。要精准复现我们需要先拆解这些核心特征并规划在Godot中的实现路径。2.1 PSX视觉特征深度拆解低顶点精度与顶点抖动PS1的GPU没有浮点运算单元顶点坐标在转换到屏幕空间后会被舍入到整数像素坐标。这导致了模型在移动或旋转时顶点会“卡顿”到最近的像素点上产生特有的抖动感。这不是简单的模型抖动而是与摄像机视角相关的、基于屏幕空间的量化现象。仿低分辨率渲染与像素化PS1的标准输出分辨率是320x240NTSC或384x288PAL。在现代高分辨率屏幕上直接显示会过于模糊。我们需要将3D场景渲染到一个低分辨率的RenderTarget上然后再放大到屏幕从而产生清晰的、块状化的像素边缘。色彩深度限制与色彩抖动PS1使用16位色深RGB565能显示的颜色数量远少于现代的24位或32位真彩色。这会导致色彩过渡出现明显的色带。为了缓解这个问题游戏开发者会使用色彩抖动技术通过在一个像素块内混合相邻颜色来模拟更多的中间色调。仿纹理扭曲与UV精度限制由于UV插值精度不足和仿射纹理映射而非透视校正PS1游戏中的纹理在特定角度下会产生著名的“纹理扭曲”效果。虽然完全模拟仿射映射比较复杂但我们可以通过限制UV的精度来模拟出一种类似的、粗糙的纹理采样感觉。子系统与光照风格PS1没有硬件光照光照计算通常是顶点级别的Gouraud着色并且颜色数有限。我们可以使用Toon着色或简单的兰伯特模型并将最终颜色输出进行量化来模拟这种平坦而分阶的光照效果。2.2 Godot 4 实现方案总览基于以上分析我设计的Godot实现方案主要围绕自定义着色器和渲染管线配置展开顶点抖动在顶点着色器中将世界坐标或视图坐标转换到屏幕空间后对其进行“舍入”操作然后再转换回世界或视图空间从而实现基于屏幕像素的顶点捕捉。仿低分辨率使用一个SubViewport节点作为我们的“PSX屏幕”。将整个3D场景放入这个SubViewport中并将其大小设置为目标低分辨率如320x240。然后用一个全屏的ColorRect或Sprite2D来显示这个SubViewport的纹理并设置为“Nearest Neighbor”过滤以实现像素完美的放大。色彩量化与抖动在片段着色器中对输出的RGB颜色值进行量化例如从0-1的浮点数量化到有限的整数阶梯并应用Bayer矩阵或蓝噪声纹理进行有序抖动以打破色带。纹理UV限制在片段着色器中对输入的UV坐标进行舍入或量化操作降低其精度模拟低精度UV插值的效果。风格化光照结合使用Godot的StandardMaterial3D的自定义着色器功能或者直接使用ShaderMaterial实现一个简化的、颜色量化的光照模型。这个方案的优势在于模块化。每个效果都可以独立调试最后再组合起来。我们首先从搭建渲染框架开始。3. 构建基础渲染框架搭建PSX屏幕在动手写着色器之前我们需要先搭建好Godot的渲染结构。这是所有后续效果的基础容器。3.1 创建SubViewport作为渲染画布新建一个Godot 4项目选择“Forward”渲染器兼容性更好适合自定义着色器。在主场景中删除默认的Node3D添加一个SubViewport节点命名为PSXViewport。关键配置大小将其Size设置为320, 240。这就是我们的“PSX屏幕分辨率”。你可以根据喜好调整为384, 288或其他低分辨率。渲染目标确保Render Target的Clear Mode为AlwaysUpdate Mode为Always这样每一帧都会清空并渲染。抗锯齿将Msaa 2D和Msaa 3D都设置为Disabled。抗锯齿会模糊像素边缘我们要的是硬朗的像素感。HDR将HDR设置为Disabled。PSX时代没有HDR禁用它可以简化色彩处理也更符合时代感。渲染信息勾选Render Info方便后续调试查看绘制调用次数。注意SubViewport是一个独立的渲染世界。其内部的3D场景渲染结果会输出到一张纹理上这张纹理的大小就是Size的设置值。3.2 构建内部3D场景与外部显示在PSXViewport节点下添加一个Node3D作为子节点命名为World。我们所有的3D模型、灯光、摄像机都将放在这个World节点下。在World下添加一个Camera3D调整好位置和角度使其能观察到场景。将其Projection设为PerspectiveFov可以设大一点如75-85度模拟老式电视的观看感觉。现在回到主场景PSXViewport的同级添加一个ColorRect节点命名为ScreenDisplay。将其锚点拉满全屏。为ScreenDisplay创建一个新的ShaderMaterial。创建一个新的Shader文件如screen_display.gdshader并将其赋值给ScreenDisplay的ShaderMaterial。在这个着色器的fragment()函数中我们暂时简单地输出PSXViewport的纹理shader_type canvas_item; uniform sampler2D viewport_texture : source_color; // 来自SubViewport void fragment() { COLOR texture(viewport_texture, UV); }关键一步将PSXViewport的纹理链接过来。在ScreenDisplay的ShaderMaterial参数中你应该能看到一个viewport_texture的uniform。点击下拉箭头选择New ViewportTexture然后在弹出的选择器中选择我们创建的PSXViewport。最后将ScreenDisplay的ColorRect的Texture Filter属性设置为Nearest。这是实现像素化放大的关键如果设为Linear低分辨率纹理在放大时会进行平滑插值从而失去像素感。至此基础框架搭建完成。运行项目你应该能看到一个可能很小的、像素化的3D场景窗口。接下来我们开始注入PSX的灵魂——着色器效果。4. 核心着色器效果实现详解我们将把主要效果编写在World中模型的材质着色器里。为了集中管理我建议创建一个自定义的ShaderMaterial然后应用到所有需要PSX风格的模型上。4.1 实现顶点抖动Vertex Snapping这是PSX风格最标志性的效果。原理是在顶点着色器阶段将裁剪空间Clip Space的坐标进行量化。为你的测试模型比如一个BoxMesh创建一个ShaderMaterial并新建一个Shader如psx_vertex_snap.gdshader。编写顶点着色器部分shader_type spatial; // 定义一个控制抖动强度的uniform通常为屏幕像素大小 uniform float pixel_size : hint_range(1, 10) 2.0; void vertex() { // 1. 将顶点位置转换到裁剪空间 vec4 clip_pos PROJECTION_MATRIX * VIEW_MATRIX * MODEL_MATRIX * vec4(VERTEX, 1.0); // 2. 进行透视除法得到标准化设备坐标NDC范围[-1, 1] vec2 ndc clip_pos.xy / clip_pos.w; // 3. 将NDC转换到屏幕空间坐标假设屏幕大小为viewport_size // 这里我们使用一个近似值或者可以传递一个屏幕分辨率uniform进来。 // 更精确的做法是使用 VIEWPORT_SIZE 或者自己传递一个 screen_size uniform。 vec2 screen_pixels ndc * vec2(320.0, 240.0); // 使用我们的PSX分辨率 // 4. 对屏幕像素坐标进行“捕捉”量化 screen_pixels floor(screen_pixels / pixel_size) * pixel_size; // 5. 将量化后的屏幕坐标转换回NDC ndc screen_pixels / vec2(320.0, 240.0); // 6. 将修改后的NDC写回clip_pos并逆转换到视图空间 clip_pos.xy ndc * clip_pos.w; // 7. 将修改后的裁剪空间位置赋值给内置变量 POSITION clip_pos; // 注意NORMAL, UV等变量需要正常传递不受此影响 NORMAL (MODEL_MATRIX * vec4(NORMAL, 0.0)).xyz; UV UV; }原理剖析我们不是在3D空间抖动顶点而是在2D屏幕空间。通过将顶点投影到屏幕上量化其像素位置再投影回去实现了顶点位置被“吸附”到屏幕像素网格上的效果。pixel_size参数控制吸附的网格大小值为1时是严格的PSX硬件模拟抖动感最强值越大网格越粗抖动感越弱但模型看起来更“块状化”。实操心得直接修改POSITION会干扰深度计算在某些复杂场景中可能导致深度冲突Z-fighting。一个更稳定、兼容性更好的替代方案是将上述计算放在vertex()函数中但将结果存储在一个varying变量中然后在fragment()函数中通过discard或深度偏移来模拟不这不可行。实际上修改POSITION是标准做法。遇到深度问题可以尝试轻微调整摄像机的Near和Far平面或者对模型应用一个微小的Depth Bias在材质的“渲染优先级”或通过着色器render_mode设置。4.2 实现色彩量化与有序抖动色彩量化是为了模拟有限的色深有序抖动则是为了在量化后平滑色带。在同一个着色器的fragment()函数中我们先处理光照简化版兰伯特和纹理采样得到一个初始颜色base_color。实现色彩量化函数vec3 quantize_color(vec3 color, float levels) { // levels 代表每个颜色通道的阶梯数例如 6.0 表示 RGB各有6级 return floor(color * levels) / levels; }实现Bayer矩阵有序抖动。Bayer矩阵是一个阈值矩阵用于决定在何处“向上”或“向下”舍入颜色值。// 一个4x4的Bayer矩阵归一化到[0, 1) float bayer_matrix[16] float[16]( 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0, 12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0, 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0, 15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 ); float get_bayer_threshold(vec2 coord) { ivec2 pos ivec2(mod(coord, 4.0)); return bayer_matrix[pos.y * 4 pos.x]; }在fragment()中组合使用void fragment() { // 假设 base_color 是从纹理和光照计算得到的颜色 vec3 base_color texture(TEXTURE, UV).rgb * (dot(NORMAL, LIGHT_DIRECTION) * 0.5 0.5); float levels 6.0; // 量化等级 vec3 quantized quantize_color(base_color, levels); // 应用有序抖动根据Bayer阈值决定是否将颜色向上提升一个阶梯 float threshold get_bayer_threshold(SCREEN_UV * VIEWPORT_SIZE); vec3 dither (1.0 / levels) * step(threshold, fract(base_color * levels)); vec3 final_color quantized dither; ALBEDO final_color; }原理剖析quantize_color函数将连续的颜色值阶梯化。fract(base_color * levels)得到的是当前颜色值超出当前阶梯的小数部分。将这个小数部分与Bayer矩阵提供的阈值比较如果大于阈值就在量化后的颜色上加上一个阶梯的高度(1.0/levels)从而在像素级别混合颜色从视觉上减少色带。SCREEN_UV是当前片段在屏幕上的坐标0-1VIEWPORT_SIZE是SubViewport的大小320,240SCREEN_UV * VIEWPORT_SIZE得到屏幕像素坐标用于索引Bayer矩阵。4.3 模拟低精度UV与纹理扭曲我们可以通过量化UV坐标来近似模拟低精度插值。void fragment() { // 在纹理采样前对UV进行量化 float uv_quantize_factor 64.0; // 量化因子值越小精度越差风格化越强 vec2 quantized_uv floor(UV * uv_quantize_factor) / uv_quantize_factor; // 使用量化后的UV进行纹理采样 vec4 tex_color texture(TEXTURE, quantized_uv); // ... 后续颜色处理 }这个效果相对简单但它能有效破坏纹理的平滑过渡产生一种“马赛克”感与整体低分辨率风格很搭。你可以根据纹理的细节程度调整uv_quantize_factor。4.4 整合与优化构建完整的PSX材质将以上所有效果整合到一个着色器中并暴露一些参数以便于在Godot编辑器内调节shader_type spatial; // 顶点抖动参数 uniform float vertex_snap_pixel_size : hint_range(1, 20) 2.0; // 色彩量化参数 uniform float color_levels : hint_range(2, 16) 6.0; uniform bool enable_dither : hint_range(0, 1) 1; // UV量化参数 uniform float uv_quantize_factor : hint_range(8.0, 256.0) 64.0; // 风格化光照参数 uniform vec3 light_dir vec3(0.5, 1.0, 0.5); uniform float light_wrap 0.2; // 光照包裹让暗部不那么死黑 // Bayer矩阵 float bayer_matrix[16] float[16](0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0)/16.0; float get_bayer_threshold(vec2 coord) { ivec2 pos ivec2(mod(coord, 4.0)); return bayer_matrix[pos.y * 4 pos.x]; } vec3 quantize_color(vec3 color, float levels) { return floor(color * levels) / levels; } void vertex() { // 顶点抖动实现同上略 // ... POSITION clip_pos; } void fragment() { // 1. 量化UV并采样纹理 vec2 quantized_uv floor(UV * uv_quantize_factor) / uv_quantize_factor; vec4 tex_sample texture(TEXTURE, quantized_uv); // 2. 简化光照计算顶点法线已传递 light_dir normalize(light_dir); float ndotl dot(NORMAL, light_dir); ndotl clamp(ndotl light_wrap, 0.0, 1.0) / (1.0 light_wrap); // 简单的光照包裹 vec3 lit_color tex_sample.rgb * ndotl; // 3. 色彩量化 vec3 quantized quantize_color(lit_color, color_levels); // 4. 有序抖动 vec3 final_color quantized; if (enable_dither) { float threshold get_bayer_threshold(SCREEN_UV * VIEWPORT_SIZE); vec3 dither (1.0 / color_levels) * step(threshold, fract(lit_color * color_levels)); final_color dither; } ALBEDO final_color; ALPHA tex_sample.a; }将这个着色器保存并应用到你的场景模型上。现在你应该能看到一个结合了顶点抖动、像素化、量化色彩和粗糙纹理的典型PSX风格模型了。5. 环境氛围与后期处理增强核心模型效果完成后我们需要营造整体的环境氛围这包括灯光、雾效和全屏后处理。5.1 风格化灯光与阴影配置PSX游戏很少使用实时阴影即便有也是基于精灵Sprite的假阴影或者非常粗糙的“斑点”阴影。在Godot中我们可以用一些技巧来模拟。禁用实时阴影在DirectionalLight3D或OmniLight3D上将Shadow Enabled关闭。或者使用一个低分辨率的Shadow Atlas Size如512并将Filter设为Nearest这样阴影边缘会呈现块状锯齿符合风格。使用顶点光照在模型的ShaderMaterial中设置render_mode为vertex_lighting。这会让光照计算在顶点级别进行然后在片段间插值产生戈劳德着色Gouraud Shading的效果这正是PSX的典型特征。但注意这可能与我们自定义的片段着色器冲突。一个折中方案是在我们的着色器开头加上render_mode diffuse_lambert, specular_disabled;并使用简单的兰伯特模型这也能模拟出平坦的光照感。烘焙光照贴图Lightmap对于静态场景使用低分辨率的光照贴图是更高效、更符合时代的选择。在Godot中设置好光照探头LightmapGI和LightmapProbe然后进行烘焙。烘焙后的光线效果是顶点级别的非常契合PSX风格。5.2 添加顶点色雾效与扫描线雾效能极大地增强场景的深度感和氛围。PSX的雾效通常是基于顶点距离的颜色单一。顶点雾在着色器的vertex()函数中计算雾因子。uniform vec4 fog_color : source_color vec4(0.5, 0.6, 0.7, 1.0); uniform float fog_start 10.0; uniform float fog_end 30.0; varying float fog_factor; void vertex() { // ... 之前的顶点抖动代码 ... // 计算顶点到相机的距离在视图空间 vec3 view_pos (VIEW_MATRIX * MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; float dist -view_pos.z; // 视图空间中相机朝向是-z方向 fog_factor clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0); }在fragment()函数中混合雾色void fragment() { // ... 计算 final_color ... final_color mix(final_color, fog_color.rgb, fog_factor); ALBEDO final_color; }全屏扫描线这是一个经典的CRT显示效果可以叠加在最终的ScreenDisplay着色器上。修改screen_display.gdshadershader_type canvas_item; uniform sampler2D viewport_texture : source_color; uniform float scanline_intensity : hint_range(0, 1) 0.1; uniform float scanline_count : hint_range(100, 600) 240.0; // 匹配垂直分辨率 void fragment() { vec3 color texture(viewport_texture, UV).rgb; // 添加扫描线暗线 float scanline sin(UV.y * scanline_count * 3.14159); scanline (scanline * 0.5 0.5) * scanline_intensity (1.0 - scanline_intensity); color.rgb * scanline; // 可选的轻微弯曲和色差效果模拟CRT凸面屏 // vec2 crt_uv UV * 2.0 - 1.0; // float curve crt_uv.y * crt_uv.y * 0.01; // 轻微垂直弯曲 // crt_uv.y curve; // crt_uv crt_uv * 0.5 0.5; // color.r texture(viewport_texture, crt_uv vec2(0.001, 0.0)).r; // color.b texture(viewport_texture, crt_uv - vec2(0.001, 0.0)).b; // 使用前需要确保UV没有越界 COLOR.rgb color; }5.3 音频处理低保真音效视觉风格需要听觉配合。PSX的音效由于内存限制通常是低采样率、单声道或经过压缩的。导入设置在Godot的导入面板中对于WAV或AIFF音效文件可以强制设置循环模式根据需求设置。比特率可以尝试降低如从1411kbps降到256kbps。在引擎中更直接的方法是使用AudioStreamPlayer并编写一个简单的AudioEffect来模拟低保真但这涉及DSP编程较为复杂。实用技巧一个更简单的方法是在外部使用音频编辑软件如Audacity对音效进行预处理降低采样率从44100Hz或48000Hz降到22050Hz或11025Hz。降低位深从16-bit降到8-bit。添加轻微的失真或滤波模拟早期音频硬件的质感。 然后将处理后的音频导入Godot使用。6. 性能优化与常见问题排查当场景复杂起来后性能问题和新问题就会出现。这里分享一些优化和排查经验。6.1 性能优化要点Draw Call管理PSX风格本身顶点简单但过多的材质和物体仍会导致Draw Call上升。尽量合并使用相同材质的静态网格使用MeshInstance3D的Mesh合并工具或手动优化减少材质变体。着色器复杂度我们的着色器包含了顶点和片段计算。vertex_snap_pixel_size设得越小如1计算越频繁。如果性能吃紧可以适当调大此值。色彩量化和抖动的计算量很小通常不是瓶颈。SubViewport开销整个3D场景都在一个SubViewport中渲染这本身有一点点开销但现代硬件完全可以承受。确保SubViewport的尺寸不要无意中被设得过大。使用LOD细节层次对于远处的物体可以使用更简单的模型和更低精度的顶点抖动更大的pixel_size甚至使用Impostor广告牌来替代。6.2 常见问题与解决方案实录问题1物体边缘出现闪烁或深度冲突Z-fighting。原因顶点抖动剧烈修改了裁剪空间坐标可能干扰了深度值的精度。解决方案调整摄像机的Near和Far平面距离使其尽可能贴近场景的实际范围为深度缓冲提供更高的精度。在材质的“渲染优先级”中为容易冲突的物体设置不同的Render Priority值强制指定绘制顺序。在着色器中尝试使用render_mode depth_prepass_alpha;但这主要用于透明物体。轻微调整模型的缩放或位置有时能避开冲突点。问题2色彩抖动在物体移动时产生令人不快的网状或闪烁图案。原因Bayer矩阵是静态的当物体或相机移动时屏幕像素与阈值矩阵的对齐关系发生变化导致抖动图案“游动”。解决方案这是有序抖动的固有特性某种程度上也是复古感的一部分。如果不喜欢可以尝试使用空间上更均匀的蓝噪声Blue Noise纹理来代替Bayer矩阵蓝噪声的视觉噪点更柔和、更随机。可以在片段着色器中采样一张预计算的蓝噪声纹理。降低抖动强度dither的贡献值。完全关闭抖动接受色带这也是一种纯粹的PSX风格。问题3SubViewport显示的内容有延迟或不同步。原因SubViewport的Update Mode可能被误设。解决方案确保PSXViewport的Update Mode设置为Always。如果场景完全静态可以设为Once或When Visible以节省性能但动态场景必须用Always。问题4想对UI/HUD也应用像素化效果但UI是2D的。思路可以将UI也渲染到一个单独的、低分辨率的SubViewport中然后通过一个Sprite2D用Nearest过滤方式显示到屏幕上。或者更简单的方法是所有UI元素都使用低分辨率的位图字体和纹理并确保CanvasItem的Texture Filter设置为Nearest。问题5如何实现PSX风格的“抖动透明”如《最终幻想7》中菜单的半透明效果方案这种透明不是Alpha混合而是棋盘格抖动。在片段着色器中可以根据屏幕位置决定是否丢弃discard该片段。bool checkerboard (int(FRAGCOORD.x) int(FRAGCOORD.y)) % 2 0; if (checkerboard tex_sample.a 0.5) { // 假设alpha值控制是否应用抖动透明 discard; }这样就会产生一个50%不透明度的棋盘格图案模拟低精度透明。经过以上步骤一个从内到外散发着PSX时代气息的Godot项目就初具雏形了。最重要的是不断调整参数——顶点抖动的强度、色彩量化的阶数、雾效的范围、扫描线的明显程度——直到找到最能触动你记忆深处那个“味道”的组合。这种风格化渲染的魅力就在于它没有绝对正确的参数只有最能表达你心中美学的那个平衡点。