HDR、浮点中间缓冲与曝光出口

HDR、浮点中间缓冲与曝光出口:保留 LearnOpenGL 3.3 Core 正文机制,以 context—资源—结果合同、GPU 轨迹和章专属单故障完成可重放验收。

学习目标

  • 能解释普通帧缓冲为什么把颜色截在 [0,1] 会让强光区死白丢细节,以及浮点帧缓冲RGBA16F)凭什么能先把算出来 >1 的颜色完整存下来
  • 能改出 HDR 上屏效果:在 demo 里切 色调映射 模式(直接截断 / Reinhard / 曝光)把死白救成有层次,拖曝光值看暗处提亮、亮处压住的此消彼长;并能写出「第一遍渲进浮点 FBO、第二遍 tonemap 再 gamma 校正上屏」的两遍流程
  • 能回答:为什么色调映射(tonemap)必须放在 gamma 校正之前?顺序反了会怎样?

为什么逆光拍照:要么窗外白成一片,要么屋里黑成一团

你一定拍过这种照片:人站在窗前,想把人和窗外的风景一起拍清楚。可手机一拍——要么窗外亮得白成一片、什么都看不见,要么为了看清窗外把曝光压低,结果屋里的人黑成一团。亮处和暗处,相机好像只能照顾一头。

渲染里也是同一个坎。一个场景里常常既有一扇极亮的窗(亮到刺眼)、又有墙角的暗部细节。问题在于:屏幕能显示的亮度就那么一个范围,最亮也就是「纯白」。窗户那块算出来远比纯白还亮,可一上屏就被强行摁成纯白——它和旁边稍暗一点的高光全糊成同一片死白,层次全没了。

这一章要解决的就是这件事:怎么让一张图同时照顾住极亮的窗和昏暗的墙角。办法分两步——先用一块「能装下超亮颜色」的画板把场景原样存住,再像调相机曝光一样把它柔和地压回屏幕能显示的范围。没有它,你的强光区永远是一坨没细节的死白;有了它,亮处暗处都能看清。

死白的根源:普通帧缓冲只装得下 [0,1]

先说清死白是怎么来的。你之前画到的帧缓冲,每个颜色通道用 8 位整数存(RGBA8),能表示的范围被锁死在 (低动态范围)的 01 之间——0 是黑、1 是能显示的最亮(纯白),再没有「比纯白还亮」这回事。

可光照算出来的颜色根本不守这个规矩。一扇正对太阳的窗、一盏贴脸的强灯,按公式(多盏光叠加、强光源)算出来的值轻松就 3.06.0——远比 1.0 大。这些「亮过头」的值一旦写进 LDR 帧缓冲,就被无情地 截断(clamp)成 1:窗户中心算出 6.0、窗框边上算出 2.5,写进去全成了 1.0,本该有的明暗层次在这一刻就永久丢了。这就是一个场景能容纳的明暗跨度——它的 ——被这块小画板硬生生砍掉了一截。下面这张图把「截断 vs 保留」上下排开:

同一条强度轴:截断 vs 保留强度=1(普通帧缓冲上限)普通帧缓冲(RGBA8,每通道只存 0~1)>1 全压成纯白·层次丢失HDR 浮点帧缓冲(RGBA16F,能存 >1)>1 完整存下·高光仍有层次0 暗6 极亮的窗 →
上排普通帧缓冲每通道只存 [0,1],光强超过 1 的整段全被压成纯白——高光层次在写入时就丢了;下排HDR 浮点帧缓冲>1 的范围完整存下,高光仍有层次,留给色调映射再压回来。

上排普通帧缓冲,强度过了 1 就全是纯白;下排我们想要的,是把 >1 那一截完整存下来

解法第一步:用浮点帧缓冲先把 >1 存住

既然 8 位整数装不下 >1 的颜色,那就换一块能装下的画板。这就是 :把帧缓冲的颜色附件从普通 8 位纹理换成 16 位浮点纹理RGBA16F)。浮点数能轻松表示 6.0100.0 这种远超 1 的值,于是窗户的 6.0、窗框的 2.5 都被原封不动地存进去,一个细节都不丢。

这一步就是整套 (高动态范围)做法的开端:跟帧缓冲那章一样另开一块离屏画板,只不过这次它的颜色附件是浮点纹理。第一遍把整个场景照常渲进这块浮点帧缓冲——所有 >1 的高光都安然存住。但它还不能直接上屏:屏幕只认 [0,1],你把 6.0 直接丢给屏幕,照样被截成纯白。还差关键的第二步。

解法第二步:色调映射把大范围压回 [0,1]

存住了大范围,怎么把它「塞进」屏幕只认的 [0,1]、又不丢层次?答案是 (tone mapping)。它不是简单地一刀切(那就退回死白了),而是用一个平滑压缩的函数:越亮的地方压得越狠,但亮处之间的相对层次保留下来。于是 6.02.5 被压成两个不同的、都在 [0,1] 内的值——窗户中心和窗框终于分得开了。

本章用两种最常见的映射,下面这张曲线图把它们和「直接截断」画在一起对照:

三条色调映射曲线:把 HDR 压回 [0,1]105HDR 输入(远超 1)→输出值1clamp = min(x,1)(>1 死白)Reinhard = x/(x+1)exposure = 1−exp(−x·k)
红线 clamp 到 1 就水平封顶——>1 的高光全压成死白、毫无层次;紫线 Reinhard 与灰虚线 exposure 都平滑趋近 1,把任意大的 HDR 输入压回可显示范围、高光区仍有层次

第一种是 Reinhardc / (c + 1)。它把任意大的 c 都平滑地压进 [0,1)——c=1 压成 0.5c=6 压成约 0.86c 再大也只是越来越逼近 1 但永远到不了,所以高光区始终有层次、绝不死白。简单、均匀,是最常用的入门映射。

第二种是 (exposure)映射:1 - exp(-c * 曝光)。它多了一个曝光值让你像调相机一样权衡——曝光调大,暗处被快速提亮、看清墙角细节,但亮处更容易顶到接近 1;曝光调小,亮处保留更多层次、看清窗外,但暗处偏黑。亮处暗处此消彼长,正是逆光拍照里那个取舍,现在交到你手里。

别忘了承接上一章:先 tonemap,再 gamma 校正

色调映射把颜色压回了 [0,1],但还没上屏——别忘了上一章的 gamma 校正:所有光照计算(包括 tonemap)都在线性空间做,最后一步才 pow(color, 1/2.2) 编码回 sRGB 给屏幕。所以完整的出口顺序是:先色调映射、再 gamma 校正

顺序为什么不能反?tonemap 是在线性空间里把亮度压缩,它假设输入是线性的;要是先做了 gamma 校正(颜色已被提亮、不再线性),再拿去 tonemap,压缩曲线就作用在错误的数值上,颜色全乱。记牢这条出口流水线:浮点 FBO 存住 HDR → 色调映射压回 [0,1] → gamma 校正编码回 sRGB → 上屏。这一步的坑后面误区还会细说。

单步确认:从线性亮度到显示器

实现 HDR 时,先确认数据在哪个空间、什么格式里,再关心公式。下面三步分别锁住「高光不会过早丢失」和「出口顺序不会反」两个不变量。

分步1 / 3

线性空间允许亮度大于 1

多盏光相加、发光材质和反射都可能令线性颜色分量超过 1。这不是错误;错误是过早把它写进归一化的 RGBA8 附件。

动手:切色调映射模式,把死白的窗救回来

猜一猜:下面是一条走廊,尽头有一扇极亮的窗(算出来的颜色值约是 6.0——比纸白还亮 6 倍),两侧墙面有暗部纹理。先看「直接截断(clamp)」模式:你觉得那扇窗会显出窗框的层次,还是糊成一整块死白?而两侧昏暗的墙角,你能看清纹理吗?先切一下,再读下面。

这块画布里,片段着色器直接算出了一个高动态范围的场景:窗户中心亮到 6.0、墙面只有零点几。三种 色调映射模式 在「直接截断 / Reinhard / 曝光」之间切;曝光值 滑块只在曝光模式下生效,拖它看亮处暗处此消彼长。盯着窗户和墙角,对比三种模式:

可交互

实时演示加载中…

谜底:直接截断模式下,窗户中心算出的 6.0 和窗框的 4.5 全被摁成 1.0——整扇窗糊成一块死白,窗框层次荡然无存;同时为了「不过曝」你又没法提亮,两侧墙角漆黑一片看不清纹理。切到 Reinhard6.0 被压成约 0.864.5 压成约 0.82,窗框层次透出来了,墙角细节也回来了。切到 曝光模式拖滑块:曝光调大,墙角越来越亮、但窗户更接近全白;曝光调小,窗户层次更足、但墙角转暗——这就是逆光取舍的此消彼长,全凭你一只手调。

左右擦一擦:截断 vs 色调映射

下面这个对比把「截断丢层次」和「色调映射保层次」定格成两态——左边是普通帧缓冲把 >1 全压成死白(高光层次丢失),右边是浮点帧缓冲把 >1 完整存下、再由色调映射压回带层次:

三条色调映射曲线:把 HDR 压回 [0,1]105HDR 输入(远超 1)→输出值1clamp = min(x,1)(>1 死白)Reinhard = x/(x+1)exposure = 1−exp(−x·k)
红线 clamp 到 1 就水平封顶——>1 的高光全压成死白、毫无层次;紫线 Reinhard 与灰虚线 exposure 都平滑趋近 1,把任意大的 HDR 输入压回可显示范围、高光区仍有层次
色调映射(>1 保留 · 有层次)
同一条强度轴:截断 vs 保留强度=1(普通帧缓冲上限)普通帧缓冲(RGBA8,每通道只存 0~1)>1 全压成纯白·层次丢失HDR 浮点帧缓冲(RGBA16F,能存 >1)>1 完整存下·高光仍有层次0 暗6 极亮的窗 →
上排普通帧缓冲每通道只存 [0,1],光强超过 1 的整段全被压成纯白——高光层次在写入时就丢了;下排HDR 浮点帧缓冲>1 的范围完整存下,高光仍有层次,留给色调映射再压回来。
截断(>1 死白 · 层次丢失)

左侧那条强度轴超过 1 的整段全是纯白——这正是 clamp 模式下窗户的样子;右侧三条曲线里,红线 clamp 到 1 就封顶(还是死白),而紫线 Reinhard、灰虚线 exposure 平滑趋近 1,把同样 >1 的高光压成一段段不同的灰、层次保住了。一句话:截断是「砍掉超出的」,色调映射是「柔和地压缩进来」。

代码逐段拆解

HDR 的代码就三块,和帧缓冲那章的两遍渲染同构,只是颜色附件换成了浮点纹理建浮点 FBO第一遍渲进它第二遍 tonemap + gamma 上屏

第一块:建一个 RGBA16F 浮点帧缓冲

和普通帧缓冲唯一的区别在颜色纹理的内部格式——从 RGB/RGBA8 换成 RGBA16F(16 位浮点),这样才存得下 >1。WebGL2 里要先启用 EXT_color_buffer_float 扩展,否则浮点纹理不能当可渲染的颜色附件:

// 浮点颜色纹理:内部格式 GL_RGBA16F(16 位浮点,存得下 >1)
unsigned int hdrFBO;
glGenFramebuffers(1, &hdrFBO);
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
unsigned int colorBuffer;
glGenTextures(1, &colorBuffer);
glBindTexture(GL_TEXTURE_2D, colorBuffer);
// 关键:GL_RGBA16F 内部格式,数据类型 GL_FLOAT
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, W, H, 0, GL_RGBA, GL_FLOAT, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, colorBuffer, 0);
// (深度 renderbuffer 附件 + checkFramebufferStatus 同帧缓冲那章,从略)

两端逻辑一一对应,差异都集中在「浮点纹理能不能渲」这件事上:

第二块:第一遍——把场景渲进浮点 FBO

绑上浮点 FBO,开深度测试,把整个场景照常渲一遍。光照算出来 >1 的高光,这一遍原封不动地写进了 RGBA16F 颜色纹理——一个细节都不丢,这一遍不做任何 tonemap、也不 gamma 校正

// 第一遍:绑浮点 FBO,正常渲场景(>1 的高光原样写进 RGBA16F 纹理)
glBindFramebuffer(GL_FRAMEBUFFER, hdrFBO);
glEnable(GL_DEPTH_TEST);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
RenderScene();   // 强光源、多盏灯叠加,算出来 >1 的颜色照样存下
glBindFramebuffer(GL_FRAMEBUFFER, 0);   // 解绑,准备第二遍上屏

第二遍才是 HDR 的关键:绑回默认帧缓冲、画一个全屏四边形采样这张浮点纹理,在它的片段着色器里做 色调映射 + gamma 校正。下面这段就是那个 tonemap 着色器。

第三块:第二遍——tonemap + gamma 上屏(核心)

采样浮点纹理拿到原始 HDR 颜色,按所选模式做 Reinhard 或曝光映射压回 [0,1]最后一步再 gamma 校正编码回 sRGB。注意这三行的先后:tonemap 在前、gamma 在后:

#version 300 es
precision highp float;
in vec2 TexCoords;
uniform sampler2D hdrBuffer;
out vec4 FragColor;
void main() {
  // 1) 采样浮点纹理,拿到原始 HDR 颜色(可能 >1)
  vec3 hdr = texture(hdrBuffer, TexCoords).rgb;
  // 2) 色调映射:Reinhard c/(c+1),平滑压回 [0,1)、保住高光层次
  vec3 mapped = hdr / (hdr + vec3(1.0));
  // 3) 再 gamma 校正(顺序:先 tonemap 后 gamma!)编码回 sRGB 上屏
  FragColor = vec4(pow(mapped, vec3(1.0 / 2.2)), 1.0);
}

两段就第 2 行不同——Reinhard 是固定的 c/(c+1),曝光多了个 exposure uniform 让你实时调。第 1 行采样、第 3 行 gamma 校正完全一样。整套出口顺序固定:采样 HDR → tonemap 压回 [0,1] → gamma 校正 → 上屏,三步谁也不能换位置(尤其 tonemap 和 gamma,反了颜色全乱,下面误区细说)。

容易踩的坑

小结

  • 普通帧缓冲(RGBA8)每通道只存 [0,1],光照算出 >1 的高光在写入时就被截成死白、层次永久丢失
  • 解法第一步:用 浮点帧缓冲RGBA16F)当颜色附件,第一遍把场景渲进它,>1 的高 动态范围 颜色原样存住
  • 解法第二步:第二遍做 色调映射——Reinhard c/(c+1) 平滑均匀压缩,或 曝光 1-exp(-c*曝光) 模拟相机、亮暗可调
  • 出口顺序固定:先 tonemap 压回 [0,1],再 gamma 校正 编码回 sRGB 上屏,顺序反了颜色全乱
  • WebGL2 建 RGBA16F 可渲染 FBO 前必须启用 EXT_color_buffer_float 扩展

练习

问题 1(改 Demo 代码型) 在上面那个走廊 demo 里,亲手把「直接截断」的死白翻车现场写出来,再改成 Reinhard 救回层次。说出:① clamp 模式那行代码是什么、为什么会死白?② 改成 Reinhard 要把它换成哪一行?

问题 2(选型题 · C 型) 三个场景,色调映射该用 Reinhard 还是 曝光,各给一句理由:① 一个室内夜景,想让玩家能手动「眯眼/睁眼」适应明暗(像相机曝光);② 一个不需要交互、只要把任意亮度都稳妥压进 [0,1] 的离屏缩略图渲染;③ 一个逆光场景,美术希望调到「既看清窗外、墙角也不全黑」的那个平衡点。

问题 3(问答型) 有人做了 HDR:第一遍渲进 RGBA16F 浮点 FBO,第二遍先 pow(hdr, 1/2.2) 做 gamma 校正、再 c/(c+1) 做 Reinhard。画面颜色不对。问题出在哪?怎么改?

名词解释

本章出现的专业名词,用大白话再讲一遍。

LDR

Low Dynamic Range,低动态范围。颜色每个通道被锁在 [0,1](或 0~255 整数)的普通表示,最亮就是纯白 1,存不下「比纯白还亮」的值。亮度超过 1 的部分会被截断成 1,所以强光区糊成一片没层次的死白。普通的 RGBA8 帧缓冲就是 LDR 的。详见本章「死白的根源」一节。

动态范围

一个场景里最亮和最暗之间的亮度跨度。真实世界跨度极大(正午太阳能比阴影亮上万倍),渲染时强光源、多光叠加也会让某些区域远亮于 1。普通 LDR 帧缓冲只能表示 [0,1] 这一小段,跨度大的场景里超过 1 的高光就被压成死白、丢了层次。详见本章「死白的根源」一节。

HDR

High Dynamic Range,高动态范围。一套渲染做法:先把场景渲进能存 >1 的浮点帧缓冲(保住超亮高光的全部层次),再用色调映射把这个大范围压回屏幕能显示的 [0,1]。好处是强光区不再死白,亮处暗处都保留细节——就像相机的 HDR 模式同时照顾逆光下的亮处和暗处。详见本章「解法第一步」一节。

浮点帧缓冲

颜色附件用 16 位(或 32 位)浮点数存储的帧缓冲(WebGL2 里用 RGBA16F 这类内部格式)。浮点数能表示远大于 1 的值,所以光照算出来 >1 的高动态范围颜色可以原封不动存进去、不被截断。HDR 的第一遍渲染就是把场景渲进它。详见本章「解法第一步」一节。

色调映射

Tone Mapping,把 [0,∞) 的高动态范围颜色用一个函数平滑压回屏幕能显示的 [0,1]。关键是它不硬截断,而是越亮压得越狠、但保留相对层次,于是窗户中心和窗框不再糊成同一片白。常见做法有 Reinhard(c/(c+1))和曝光映射(1-exp(-c*曝光))。详见本章「解法第二步」一节。

曝光

一种色调映射方式,模拟相机的曝光:tonemap 时乘上一个可调的曝光值,公式常用 1 - exp(-c * 曝光)。曝光调大,暗处被快速提亮(看清昏暗细节),但亮处更易顶到接近 1;曝光调小,亮处保留更多层次(看清强光),但暗处偏黑。本质是让你像调相机一样在「照顾亮处」和「照顾暗处」之间权衡。详见本章「解法第二步」一节。

版本、来源与运行边界

本章以 Joey de Vries 的 LearnOpenGL 原章 为授权改编依据,教学运行基线是 OpenGL 3.3 Core Profile。Khronos 当前发布的规范参照是 OpenGL 4.6 Core Profile;这里用 4.6 规范核查术语和状态合同,但不把 4.6 API 偷偷倒填为原教程内容。GLFW、GLAD、Assimp 与驱动版本都属于运行环境,不能拿“编译通过”替代对 context、资源和 framebuffer 结果的验证。

正式概念与状态责任

  • hdr:在“HDR、浮点中间缓冲与曝光出口”中由floating-point scene FBO 与最终 tone-mapping pass负责解释其输入、受控状态和可观察结果;运行时以attachment format、HDR texel、exposure、tone-map 前后值与显示像素定位它的第一处变化。
  • floating point framebuffer:在“HDR、浮点中间缓冲与曝光出口”中由floating-point scene FBO 与最终 tone-mapping pass负责解释其输入、受控状态和可观察结果;运行时以attachment format、HDR texel、exposure、tone-map 前后值与显示像素定位它的第一处变化。
  • tone mapping:在“HDR、浮点中间缓冲与曝光出口”中由floating-point scene FBO 与最终 tone-mapping pass负责解释其输入、受控状态和可观察结果;运行时以attachment format、HDR texel、exposure、tone-map 前后值与显示像素定位它的第一处变化。
  • exposure:在“HDR、浮点中间缓冲与曝光出口”中由floating-point scene FBO 与最终 tone-mapping pass负责解释其输入、受控状态和可观察结果;运行时以attachment format、HDR texel、exposure、tone-map 前后值与显示像素定位它的第一处变化。

章专属 OpenGL 状态实验

先预测“在线性浮点目标累积光照,屏幕 pass 执行 tone map 后编码显示”发生后,floating-point scene FBO 与最终 tone-mapping pass应怎样改变HDR scene color、attachment format、exposure、tone-map 输出和 gamma 编码;再操作三个实验。实验不生成变化率或正确率等虚构总分,只显示真实 GL 状态、资源、命令和可观察结果。

实验一:Context—资源—结果合同

选择任一正式概念与基线/单故障场景,核对它是否进入本章状态合同。正式概念只有同时出现在解释、可视状态和交付证据中才算覆盖。

Context · resource · observable result

HDR、浮点中间缓冲与曝光出口:状态合同

让亮度先保存在浮点 framebuffer,再通过曝光或其他 tone map 压缩到显示范围

验证场景

官方教程正式概念

logl-35 · 基线帧

hdr固定 context、资源内容与输入事件,执行“在线性浮点目标累积光照,屏幕 pass 执行 tone map 后编码显示”

状态所有者floating-point scene FBO 与最终 tone-mapping pass
受控状态/资源HDR scene color、attachment format、exposure、tone-map 输出和 gamma 编码
触发命令在线性浮点目标累积光照,屏幕 pass 执行 tone map 后编码显示

冻结输入:hdr

floating-point scene FBO 与最终 tone-mapping pass记录HDR scene color、attachment format、exposure、tone-map 输出和 gamma 编码

结果:得到可重复的初始 GL 状态与资源身份

观测:attachment format、HDR texel、exposure、tone-map 前后值与显示像素中的初始快照

预期:floating-point scene FBO 与最终 tone-mapping pass得到可复查结果,并持续满足“tone map 前不 clamp 高亮;曝光改变显示映射而不改原 HDR attachment”

实验二:CPU 命令到 GPU 结果的五段轨迹

逐段执行“在线性浮点目标累积光照,屏幕 pass 执行 tone map 后编码显示”,在每一步记录资源身份、状态变化与第一个可观察结果,并持续核对“tone map 前不 clamp 高亮;曝光改变显示映射而不改原 HDR attachment”。

CPU command · GL state · GPU result

HDR、浮点中间缓冲与曝光出口:五段轨迹

选择一段命令—资源—结果1 / 5

当前观测:attachment format、HDR texel、exposure、tone-map 前后值与显示像素中的初始快照

不变量:tone map 前不 clamp 高亮;曝光改变显示映射而不改原 HDR attachment

实验三:单故障与同输入恢复

注入“场景先写入 GL_RGBA8,超过 1 的亮度已截断,后续曝光无法恢复层次”,保存首个分岔;撤销后沿用完全相同的 context、资源内容、uniform 和 draw 输入重放。只有attachment format、HDR texel、exposure、tone-map 前后值与显示像素一起恢复才算修复。

Single fault · first divergence · replay

HDR、浮点中间缓冲与曝光出口:反例与恢复

故障:场景先写入 GL_RGBA8,超过 1 的亮度已截断,后续曝光无法恢复层次

1. 冻结输入一致

第 1 次沿用同一 context、资源、uniform 与 draw 输入

2. 注入单故障一致

保持其余输入不变,仅注入“场景先写入 GL_RGBA8,超过 1 的亮度已截断,后续曝光无法恢复层次”

3. 定位首差一致

tone map 前不 clamp 高亮;曝光改变显示映射而不改原 HDR attachment

4. 清理并重放一致

attachment format、HDR texel、exposure、tone-map 前后值与显示像素

最小可重放检查

unit: logl-35
owner: floating-point scene FBO 与最终 tone-mapping pass
state_or_resource: HDR scene color、attachment format、exposure、tone-map 输出和 gamma 编码
command: 在线性浮点目标累积光照,屏幕 pass 执行 tone map 后编码显示
pass_invariant: tone map 前不 clamp 高亮;曝光改变显示映射而不改原 HDR attachment
single_fault: 场景先写入 GL_RGBA8,超过 1 的亮度已截断,后续曝光无法恢复层次
required_evidence: attachment format、HDR texel、exposure、tone-map 前后值与显示像素

复核者先仅依据以上合同写出预期,再运行基线、单故障和清理后重放。若两次基线的资源身份、首个状态变化或 framebuffer 结果不同,必须保留差异,不能用最终截图相似掩盖中间状态错误。

出处声明

本文为改编重写,改编自 Joey de Vries 的 LearnOpenGL。原文:learnopengl.com

原作及译作以 CC BY-NC 4.0 协议授权,本改编版同样遵循该协议(署名—非商业性使用)。

讨论

评论区加载中…