高考结束后,三年的高中生活也随之落幕,此后再回到这间教室的机会大概不会很多。再次回到教室,环顾四周,想到就要与这个见证了高三拼搏的地方分别,终究有些不舍。于是,我萌生了将这间教室以数字形式保存下来的想法。我选择了近年兴起的三维高斯泼溅技术,使用实拍的三百六十多张照片,“重建”了这间教室。作为第一次 3DGS 实践,最终的渲染效果已令我颇为满意。因此,我写下这篇文章,记录并复盘整个项目流程。
关于高斯泼溅
通俗地说,高斯泼溅 (3D Gaussian Splatting) 是一种根据多视角照片重建并渲染三维场景的技术。它使用大量具有位置、形状、颜色、透明度等属性的“三维高斯”来表示场景;渲染时,这些高斯会投影并混合到二维画面上。与传统的使用三角形对空间进行建模的方法相比,3DGS 不需要先构建完整的表面连接关系;与 NeRF 等神经网络模型相比,它通常训练更快,也更容易实现实时渲染。它不显式地描述物体表面的连接关系,而是用大量位置、尺度和方向各异的三维高斯表示场景。从训练视角附近观察时,通常能够渲染出十分逼真的图像。
本教程关注实践,对理论要求不高。若希望进一步了解 3DGS 的技术原理,参见B站视频:Gaussian Splatting - 高斯泼溅技术原理与应用 。
设置相机参数
我们需要从不同位置和角度采集教室照片,供 COLMAP 估计相机位姿并恢复初始的三维结构。照片使用手机拍摄即可。不建议通过拍摄视频并从中提取图片的方式,因为视频帧通常分辨率较低、压缩较重,并容易出现运动模糊,单帧细节通常不及照片。
建议使用手机(或相机)的“专业模式”拍摄,锁定相关参数,减少照片之间非几何因素造成的变化,从而提高特征匹配和后续训练的稳定性。
相关参数可以使用如下设置:
画面比例:建议选择
4:3。因为目前多数手机主摄传感器的原生画幅接近 4∶3。如“16:9”或“全屏”,实际上是手机在 4:3 的基础上进行裁剪,不是物理意义的变焦。不可以在拍摄过程中更改画面比例或数字变焦倍数,否则,不同照片将被错误地视为拥有同一组相机内部参数。白平衡:建议固定。3DGS 的训练更希望同一表面在不同照片中的颜色和亮度保持相对稳定。在白平衡不固定的情况下,由于拍摄对象处于顺光或逆光状态,在不同视角下,相机算法会对白平衡进行自动调整,使拍摄物体总体呈现比较美观的视觉效果。明显的颜色和亮度变化会降低部分特征的一致性,使 3DGS 更难用同一组高斯拟合多张照片。拍摄者需要将白平衡调节至荧光灯或日光灯水平,或者移动滑块,调节至画面光照看起来比较自然的位置,拍摄过程中不去动它。
感光度:设置成100或200,不要太高。感光度衡量感光元件对光的敏感程度。感光度越高,照片在相同拍摄条件下越亮。在低感光度的条件下,拍摄出的照片纹理清晰,边缘锐利,容易辨识。感光度升高通常会使信噪比降低(想象一下你使用一款拍照功能不那么强大的手机,在夜间拍摄室外风景,画面中遍布大量随机噪点的场景),也就是说,在相同尺度下,虚假噪点更多了。高感光度带来的大量随机噪点,极易被算法误判成物体表面的细小纹理,给特征点匹配带来误差,相机位姿估计精度下降。
对焦:拖动滑块至0.8~0.9之间,使屏幕上大部分区域清晰,拍摄过程中保持不变。若使用自动对焦,相机可能在不同照片中反复改变焦点位置,造成局部模糊程度和有效内参变化。如果在拍摄过程中切换镜头或改变数字变焦倍率,照片将具有不同的相机内参,而本文后续又使用 single_camera 处理,易导致标定失败或精度下降。
曝光补偿:设置为0。

如上图,这是我在固定上述设置后,走到教室右后方拍摄的一张照片,个人感觉是质量较高的。照片具有较好的清晰度和锐度,黑板上和墙壁上的文字,以及教室内的设施细节,都能比较清晰地辨识出来。图片中纹理比较丰富,包含地板的水磨石纹理、蓝色课桌、红色消防管道。这些纹理有助于算法更准确地估计相机位置。
拍摄流程
教室可以近似看作一个长方体空间。拍摄的目标是让墙面、地面、天花板和主要物体被多张照片从多个角度观察到,并在相邻照片之间保持充分重叠。
我的拍摄路线如下:
- 在教室的边缘走一圈
从教室前门附近,面向教室内部,沿墙内侧顺时针绕教室一圈,镜头水平略偏下,每走一步拍一张。尽可能走慢一点,步幅小一些,到教室四个角落时,再从角落向对角方向拍一张照片。共拍摄约60张。
下图为 GPT-Image-2 根据我的描述生成的路线,可参考但不必完全参考。
- 沿着教室过道,走“S”形
从教室前门出发,面向后方,沿着座位之间的过道走“S”形,镜头水平略向下。沿每条课桌间的过道拍摄,每走一步拍一张。在讲台等细节丰富的区域,从不同角度多拍几张。 - 蹲姿拍摄
降低机位,再沿相同的 S 形路线拍摄一遍。镜头水平即可。这个角度可以补充桌斗底部、椅子腿和地面纹理等在正常视角下容易被遮挡的细节。同样每走一步拍摄一张。 - 高处俯拍
蹲姿走完全程,是很累的,先休息一下。将手机举过头顶,使镜头略微朝下,仍沿前述 S 形路线拍摄。每两步拍一张即可。 - 补充局部细节
可以在保持与周围画面充分重叠的前提下,补拍天花板灯具、地面、讲台、窗户和门等区域。
需要注意的一些事项:
- 不可以在同一个位置旋转相机拍摄。前处理阶段的 COLMAP 需要利用不同相机位置产生的视差,通过三角测量恢复空间点的深度。例如:给定拍摄两张图片时的两相机中心 $C_1$ 和 $C_2$ ,同一空间点 \(P\) 在两张图像上的投影分别为 \(p_1\) 和 \(p_2\)。显然,\(p_1\) 和 \(p_2\) 分别确定了从 \(C_1\) 和 \(C_2\) 指向空间点 \(P\) 的观测方向。从相机位置出发,通过 $p_1$,$p_2$ 确定出的方向,引出两条射线,理想情况下,两条射线的交点就是空间点 \(P\) 的位置。若是 $C_1$,$C_2$ 重合,那么就没法画出两条不同的射线指向空间点 $P$,也就没法确定物体的位置了。
- 除了水平视角,还应适当向上、向下拍摄,以覆盖天花板、桌椅底部和地面;同时可以改变机位高度,增加竖直方向的视差。避免由于前述原因,垂直方向上的物体间的相对距离无法算出。
- 尽量避免拍到走动的人、摆动的窗帘等动态物体。少量动态区域未必使匹配失败,但会增加错误匹配和漂浮伪影。
前期准备
照片拍摄完成后,我计划使用 Kaggle 提供的免费算力训练模型。
打包照片
将此前拍摄的所有照片,全部放进一个文件夹。然后压缩成一个 .zip 格式的压缩包。压缩包文件名建议全英文,避免报错。注册 Kaggle 并验证手机
打开 Kaggle 官网,注册并登录后,点击右上角头像,在菜单栏中点击 “Settings” ,找到 “Phone verification” ,进行手机号验证,以获得 GPU 的使用资格。经实测,能使用中国大陆地区的手机号(+86)验证。上传数据集
点击 Kaggle 首页左侧边栏 “Data Hub” 中的 “Datasets” 进入“数据集” 页面,点击“+ New Dataset”新建数据集,将压缩包上传上去,上传完成后,Kaggle 会自动解包,不需要我们动手写解压代码。创建 Kaggle Notebook
在 Kaggle 左侧菜单边栏点击 “Create” ,选择 “Notebook”,新建一个笔记本。它是一种交互式网页文档,可以运行代码块。进入笔记本后,在页面顶栏的 “Settings” 中找到 “Accelerator”,将它改成 “GPU T4 x2”,并确认。完成上述设置后,页面应与下图大致相同。
随后,在右侧栏找到 “Input”,点击 “Add Input”,选择 “Your Datasets”,选择刚才上传的数据集,添加进项目中。
模型训练
在工作区内点击 “+”,新建一个代码块。代码块中输入代码后,可以点击形如三角形的播放按钮运行代码。
先新建两个代码块,分别粘贴如下代码并执行,它们分别用于安装环境和自动整理照片:
!apt-get update -y
!apt-get install colmap imagemagick -y
%cd /kaggle/working
!git clone --recursive https://github.com/graphdeco-inria/gaussian-splatting
%cd gaussian-splatting
!pip install -q plyfile
!pip install -q ./submodules/diff-gaussian-rasterization
!pip install -q ./submodules/simple-knn
print("环境安装完成!")
import os
import shutil
input_base = '/kaggle/input'
datasets = os.listdir(input_base)
if len(datasets) > 0:
dataset_path = os.path.join(input_base, datasets[0])
print(f"找到你上传的数据集: {dataset_path}")
else:
print("未找到数据集,请检查右侧 Input 里是否添加成功")
project_dir = '/kaggle/working/project'
input_dir = os.path.join(project_dir, 'input')
os.makedirs(input_dir, exist_ok=True)
count = 0
valid_exts = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']
for root, dirs, files in os.walk(dataset_path):
for file in files:
if any(file.endswith(ext) for ext in valid_exts):
shutil.copy(os.path.join(root, file), os.path.join(input_dir, f"img_{count}.jpg"))
count += 1
print(f"整理完毕!一共读取了 {count} 张照片。")
成功完成上述步骤之后,页面显示类似下图:
接下来,使用 COLMAP 进行相机标定与稀疏重建。也就是说,COLMAP 估计输入照片拍摄时相机的位置和朝向,计算出相机位姿。同时,它也会匹配照片中的一些共同特征点,进行三角测量,生成一个粗糙的三维点云,为后续 3DGS 初始化相机和三维高斯提供基础数据。
此外,由于底层的 COLMAP 软件带有 Qt 依赖,在无外接显示器的 Kaggle 平台上训练时,程序会因无法找到屏幕而崩溃。因此推荐使用 xvfb-run,模拟出图形环境,欺骗 COLMAP 让它以为自己在有屏幕的服务器上运行。
整个数据流可分为特征提取、特征匹配、稀疏重建、格式转换四个阶段。
对于特征提取部分的脚本,COLMAP 通常使用 SIFT 算法。SIFT 算法找到每张图片中有辨识度的部分区域,称为“特征”。每个特征包含“关键点(Keypoint)”和“描述子(Descriptor)”两个部分。关键点记录当前区域的位置、尺度和使用统计学方法得出的这块区域的“主要方向”。随后,SIFT 为每个关键点生成一个 128 维的描述向量,即“描述子”。如果两张照片拍到了同一个特征,对应的描述子的距离会较小,从而可识别出一定的空间结构。
在 COLMAP 为每张照片找到关键点并计算完描述子后,这些信息都将写入数据库,地址为/kaggle/working/project/database.db。
由于此前在数据采集的过程中,所有照片的相机、镜头、焦段都相同,故设置single_camera 1,声明相机内参相同。这可以缩短计算时间。
此外,由于普通镜头在拍摄照片时,画面会存在弯曲、拉伸等畸变情况,因此将 COLMAP 的相机模型设为 OPENCV,使相机模型包含畸变参数。
在提取完成后,COLMAP 已经知道每张图片中所包含的所有特征。不同的图片中会包含非常相似的特征,它们往往对应真实世界中的同一个空间点。一般通过计算两个描述子向量的欧氏距离衡量它们之间的相似程度。对任意两个描述子向量 \( A \) 和 \( B \),欧氏距离 \( d = \sqrt{\sum_{i=1}^{128} (A_i - B_i)^2} \)。距离越近意味着两个关键点越相似,越有可能是同一个空间处的点。
但是,不难发现,在教室场景中,很多相似的课桌椅会产生非常相似的描述子,但描述子对应的关键点可能不同。不过,倘若两张照片确实拍到了同一处场景,那么两张图上的所有相匹配的特征点之间一定满足某种特殊的几何关系。这种严格的几何关系约束能够过滤掉很多错误匹配。
对于图像的特征匹配,假如有 N 张照片,如果将所有图片两两比较,需要比较的总次数是 $\frac{N(N-1)}{2}$ 次,对于我的包含三百六十多张图片的数据集来说,将要进行六万五千多次比对,如此庞大的计算量是很可怕的。
可笑的是,我最初确实是使用穷举匹配的。数据表明,仅匹配一小部分图片就耗费了一个多小时。

考虑到我的照片都是连续拍摄的,某一张照片的画面通常和它前后几张照片的画面重叠程度较高,而和它后面上百张照片的画面重叠程度更低,所以设置 --SequentialMatching.overlap 20,让 COLMAP 主要匹配文件顺序上相邻的 20 张照片。这样,匹配次数下降至三千多次,大幅缩短了特征匹配时间。

匹配完成后,COLMAP 已经知道了相同物体在不同图像上的位置,此时,COLMAP 仍不知道这些匹配点的三维位置。稀疏重建可以恢复三维结构,但由于空间距离未知,整个模型的绝对尺度无法确定。
从二维图像中恢复三维结构需要依赖“视差”。例如,我现在正坐在电脑屏幕前写这篇文章。我将右眼闭上,并将左手食指放在左眼前某处。我的手指遮住了一部分电脑屏幕,但仅凭这一视角,很难准确判断手指与屏幕之间的距离。随后闭上左眼、睁开右眼,这时所见手指位置发生明显变化,它相对于屏幕也会发生明显移动。据此,可粗略估计手指与屏幕间的相对距离大小。将手指向前或向后移动并静止,重复上述操作,会得到明显不同的相对距离估计值。这与相机从不同位置拍摄同一物体相似:距离相机近的物体,在不同图像中移动距离较大,远的物体,移动距离较小。
COLMAP 通常会先从全部照片中选择一对合适的图像,作为稀疏重建的起点。这两张照片需要有充分的画面重叠和足够多的可靠匹配点,同时相机位置距离比较远。选定这两张图片后,算法能够计算出这两张图片的相对位置和朝向。得到相机位姿后,这两张照片就被注册到当前模型中。
对于在这两张图片上都出现的同一个空间点,COLMAP 可以从两条投影射线求交,估计该点的三维位置。其基本原理在前文“拍摄流程 ”里“需要注意的一些事项”中说明了。由于实际匹配存在误差,射线通常不会精确相交,因此求解的是使误差最小的三维点。像这样三角化三维点,COLMAP 生成了第一批稀疏的 3D 点云。
此后,COLMAP 会寻找其他图片中的特征与生成的 3D 点云之间的对应关系,反推出第三张图片的相机位姿,新相机被注册后,又可以与已有相机三角化更多三维点,整个过程不断循环。
不过,前面所说的每一步都存在不可避免的误差,在不断加入图片的过程中,误差会累积,COLMAP 会在加入一部分照片后,对所有相机参数和所有三维点进行优化、微调。对于已知的一个三维点和给定的相机位姿和相机内参,COLMAP 预测该三维点在对应照片中的投影坐标 \((x,y)\)。此位置与实际观测到的关键点位置之间的距离,就是重投影误差。COLMAP 会执行 Bundle Adjustment,使用最小二乘法,调整相机参数和三维点空间坐标,使全部照片的总重投影误差尽可能小。
需要使用参数--Mapper.ba_global_function_tolerance=1e-6控制 Bundle Adjustment 迭代的停止。当目标函数的相对改善幅度低于该阈值时,优化即可停止。
稀疏重建完成后,考虑到全部照片彼此连通,生成一个模型的情况,会生成如下目录:
sparse/0/
├── cameras.bin
├── images.bin
└── points3D.bin
其中,cameras.bin保存相机的焦距、光学中心、畸变模型等内参,images.bin保存已注册的每张图片的信息,包括图片名称、相机位姿等。points3D.bin保存稀疏点云,它在三维空间中恢复了容易匹配的特征位置。
随后进行格式转换。假设 COLMAP 生成了 sparse/0,如果不存在,就在当前文件夹下寻找第一个子目录。
3DGS 仓库中的 convert.py 可以调用 COLMAP,并将原始数据整理成训练代码所需的格式。
COLMAP 允许使用 OpenCV 畸变模型,但是 3DGS 更希望输入图片符合针孔相机模型,因此需要将原始畸变图片重新采样为无畸变图片。去畸变后,相机内参也会随之更新。如果不执行格式转换和去畸变,输入有畸变的图片,3DGS 却按照无畸变的针孔相机模型计算,会导致高斯点放置错误,画面边缘模糊的异常情况。
经过上述流程,完成了 COLMAP 预处理。完整的代码如下。由于 Kaggle 通过 apt 安装的 COLMAP 是预编译好的 CPU 版本,在预处理阶段,无法使用 GPU 加速,预处理过程可能持续数小时。
%cd /kaggle/working/gaussian-splatting
import os
project_path = "/kaggle/working/project"
db_path = os.path.join(project_path, "database.db")
sparse_path = os.path.join(project_path, "sparse")
input_path = os.path.join(project_path, "input")
os.makedirs(sparse_path, exist_ok=True)
# 安装虚拟显示器工具
!apt-get install -y xvfb > /dev/null 2>&1
print("xvfb 安装完成")
def run_colmap(cmd):
full_cmd = f"xvfb-run -a -s \"-screen 0 1920x1080x24\" {cmd}"
print(f"执行命令中,请稍候")
return os.system(full_cmd)
# 第一步:特征提取
print("\n第一步:特征提取")
cmd1 = f"colmap feature_extractor --database_path {db_path} --image_path {input_path} --ImageReader.single_camera 1 --ImageReader.camera_model OPENCV"
if run_colmap(cmd1) != 0: raise RuntimeError("特征提取失败")
# 第二步:特征匹配
print("\n第二步:特征匹配")
cmd2 = f"colmap sequential_matcher --database_path {db_path} --SequentialMatching.overlap 20"
if run_colmap(cmd2) != 0: raise RuntimeError("特征匹配失败")
# 第三步:稀疏重建
print("\n第三步:稀疏重建")
cmd3 = f"colmap mapper --database_path {db_path} --image_path {input_path} --output_path {sparse_path} --Mapper.ba_global_function_tolerance=1e-6"
if run_colmap(cmd3) != 0: raise RuntimeError("稀疏重建失败")
# 第四步:格式转换
print("\n第四步:格式转换")
model_dir = os.path.join(sparse_path, "0")
if not os.path.exists(model_dir):
subdirs = [d for d in os.listdir(sparse_path) if os.path.isdir(os.path.join(sparse_path, d))]
if subdirs: model_dir = os.path.join(sparse_path, subdirs[0])
cmd4 = "python convert.py -s /kaggle/working/project --skip_matching --no_gpu"
if os.system(cmd4) != 0: raise RuntimeError("格式转换失败")
print("完成了 COLMAP 预处理!")
在完成 COLMAP 预处理后,会进入 3DGS 训练流程。
在预处理阶段,COLMAP 已经估计出相机位置、朝向以及初始三维点,train.py负责将这些点优化成能够从各个视角重现照片的三维高斯。
设置 --iterations 15000,将总训练轮数设为 15000 次,即官方默认 30000 次的一半,以实现效率和效果的平衡。
3DGS 训练的对象是大量三维高斯。可以将一个三维高斯当成一个带颜色和透明度的椭球。越靠近高斯中心,三维高斯的特征越明显,越远离中心,其特征越弱。它像一团带着颜色的雾。从不同角度观察高斯球,由于球谐函数的作用,会看到不同颜色。
3DGS 将 COLMAP 计算出的三维点初始化为高斯,规定它的中心坐标、初始的颜色、大小、透明度等参数。3DGS 会维护一个相机列表,每次迭代随机选择一张照片对应的相机。在选定照片后,用当前高斯从该相机视角渲染图片。渲染的图片与真实图片之间存在差异,这种差异被称为损失。3DGS 重点关注像素点的颜色,物体的结构、纹理是否接近真实图片。用数学方法量化出损失数值后,需要明确每个高斯的哪些参数导致了损失,以及每个高斯的参数如何修改以最小化损失。
假设一个高斯点的位置相较于三维空间中的真实位置存在偏移,它渲染出的物体位置同样会与实际位置之间出现误差。此时损失函数会产生关于位置的梯度 $\frac{\partial L}{\partial x},\frac{\partial L}{\partial y},\frac{\partial L}{\partial z}$。同理,也会出现损失对透明度、大小、颜色等参数的梯度。由于渲染过程可微,误差会从渲染图片传回高斯参数,这一过程被称作反向传播。接着,3DGS 会将相关高斯点的参数向损失下降最快的方向修改,更新部分高斯的大小、颜色、旋转和透明度等参数。
此外,训练初期,由 COLMAP 稀疏点云初始化得到的高斯数量有限。对于一些较为精密的物体,如讲台上的金属笔筒,少量高斯难以充分描绘此类精细结构,仅仅通过反向传播修改高斯点参数也无能为力。这时,3DGS 会对这些难以拟合的部分增加高斯点的数量,这一过程被称为“增密”。对于当前高斯不算太大但是也难以拟合真实场景的情况,3DGS 会在当前位置复制一个相同的高斯。两个高斯在之后的学习过程中能学到不同的参数,从而更好地拟合现实世界。此外,对于一些原本尺寸较大的高斯,在合适情况下,3DGS 会将大高斯拆分成几个更小的高斯,每个小高斯分别负责学习不同区域。
显而易见,对高斯的增密不能无限度地进行,避免显存占用增大、生成模型文件过大的情况。对于一些基本透明的高斯,它们的存在与否不会显著影响观感,这种情况下3DGS 会删除相关高斯,这一操作称为“剪枝”。
上述操作流程会在 3DGS 训练中反复迭代上万次。不同阶段的训练流程略有不同。通常,在前 500 次中,先优化现有高斯,500 次以后,开始增密。此后通常每隔 100 次执行一次增密和剪枝。15000 次之后,停止改变高斯数量。对于 3DGS 的训练,通常执行 15000 次可以快速验证流程是否成功,30000 次迭代通常更适合作为最终模型。

上述过程计算量巨大,且官方 3DGS 实现依赖 CUDA 的扩展,需要 NVIDIA GPU 才能训练。
训练完成后,会生成文件 point_cloud.ply。它是一个存储三维高斯数据的点云文件,包含所有高斯点的全部属性。
接着将此文件复制到 Kaggle 的根目录下,便于通过文件浏览器找到它。点击下载按钮,即可获得原始的点云文件。
训练代码如下:
%cd /kaggle/working/gaussian-splatting
!python train.py -s /kaggle/working/project --iterations 15000
import os
import glob
import shutil
output_dir = "/kaggle/working/gaussian-splatting/output"
try:
latest_folder = max(glob.glob(os.path.join(output_dir, '*/')), key=os.path.getmtime)
ply_file = os.path.join(latest_folder, "point_cloud", "iteration_15000", "point_cloud.ply")
if os.path.exists(ply_file):
shutil.copy(ply_file, "/kaggle/working/classroom_model.ply")
print("太棒了!模型训练成功!")
print("在网页【右侧栏】,展开【Output】→【/kaggle/working】,点击 classroom_model.ply 旁边的【下载图标】即可下载到本地!")
else:
print("找不到模型文件,可能训练未完成。")
except Exception as e:
print("保存时出现错误:", e)
压缩原始模型
训练完成后,原始的 .ply 文件内可能存在一些远离模型主体的噪点或杂乱色块。它们有碍观感,且增大了模型体积。推荐使用 SuperSplat 工具删除噪点并压缩模型。
访问 SuperSplat 官网 ,进入编辑器。将下载好的原始模型文件拖进页面。随即,模型被渲染出来,模型加载完成后,可以旋转视角检查主体结构。如图,可见获得较完美的效果,说明此前工作比较到位。

随后滚动鼠标滚轮拉远视角,使整个模型进入视野。在所有高斯点都能在网页上显示的尺度下,可见模型主体之外存在一些成簇的高斯点。它们的实际位置可能在教室外面,删去部分后对于模型效果的影响很小。
如下图,在下方工具栏选择“套索”工具,选中期望删除的区域。被选中的区域将变成黄色。按下键盘 Delete 按键,即可将其删除。删除噪点后,可以导出模型为更小格式。

点击左上角的“文件” → “导出” → “Splat (.splat)”,将原始的 .ply 文件另存为 .splat 格式文件。.splat 格式文件占用空间更小,更便于在网页中加载和渲染,有助于分享 3DGS 效果。

在本地网页预览模型
选择一个文件夹,放入导出的 .splat 文件。新建 index.html 文件。输入下面这段网页代码(假设模型文件名为 classroom.splat):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的高中教室 3DGS</title>
<style>
body { margin: 0; padding: 0; overflow: hidden; background-color: #000; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.157.0/build/three.module.js",
"@mkkellogg/gaussian-splats-3d": "https://unpkg.com/@mkkellogg/gaussian-splats-3d@0.4.0/build/gaussian-splats-3d.module.js"
}
}
</script>
</head>
<body>
<script type="module">
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
const viewer = new GaussianSplats3D.Viewer({
'initialCameraPosition': [0, 1, 3],
'cameraUp': [0, -1, 0],
'sharedMemoryForWorkers': false
});
viewer.addSplatScene('classroom.splat', {
'showLoadingUI': true,
'position': [0, 0, 0]
}).then(() => {
viewer.start();
}).catch((err) => {
console.error("加载出错了:", err);
alert("加载模型失败,请按 F12 查看控制台报错!");
});
</script>
</body>
</html>
在终端中进入该文件夹。输入命令:python -m http.server 8000,创建一个本地服务器。打开 http://localhost:8000 ,即可在浏览器中预览模型,并使用鼠标旋转或移动视角。此外,不要直接双击打开 index.html,因为浏览器的安全策略可能阻止模块脚本加载,故需要启动 HTTP 服务器。

将模型部署上云
我的方案是:网页文件部署在免费的 Cloudflare Pages 上。由于 Cloudflare Pages 单个文件限制大小不超过 25MB,我的模型不满足此条件,故选择将模型文件保存在腾讯云的对象存储服务器中。
需要留心的是,腾讯云的对象存储是收费服务,存储和流量分开计费。如果希望通过网页分享模型效果,模型就必须交给浏览器渲染,需要注意防止流量被他人盗刷。
在腾讯云的对象存储页面新建一个桶,将桶的访问权限设置为“公有读私有写”(此操作有风险,参见后文),上传 .splat 文件,记录文件分配到的 URL,供 HTML 文件中引入。
在一个空文件夹下新建 index.html,复制并按要求修改以下代码:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>我的高中教室 3DGS</title>
<style>
body { margin: 0; padding: 0; overflow: hidden; background-color: #000; touch-action: none; }
#canvas-container { width: 100vw; height: 100vh; position: absolute; z-index: 1; }
.control-panel {
position: absolute; bottom: 30px; z-index: 10;
display: grid; gap: 8px; user-select: none; -webkit-user-select: none;
}
#left-panel { left: 20px; grid-template-columns: 50px 50px 50px; grid-template-rows: 50px 50px; }
#right-panel { right: 20px; grid-template-columns: 50px 50px 50px; grid-template-rows: 50px 50px; }
.btn {
background: rgba(255, 255, 255, 0.2); backdrop-filter: blur(4px);
border: 1px solid rgba(255,255,255,0.4); border-radius: 10px;
color: white; font-weight: bold; font-size: 14px;
display: flex; justify-content: center; align-items: center;
cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.3); touch-action: none;
}
.btn:active { background: rgba(255, 255, 255, 0.5); transform: scale(0.95); }
.up-btn { grid-column: 2; grid-row: 1; }
.down-btn { grid-column: 2; grid-row: 2; }
.left-btn { grid-column: 1; grid-row: 2; }
.right-btn { grid-column: 3; grid-row: 2; }
.rotL-btn { grid-column: 1; grid-row: 1; }
.rotR-btn { grid-column: 3; grid-row: 1; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.157.0/build/three.module.js",
"@mkkellogg/gaussian-splats-3d": "https://unpkg.com/@mkkellogg/gaussian-splats-3d@0.4.0/build/gaussian-splats-3d.module.js"
}
}
</script>
</head>
<body>
<div id="canvas-container"></div>
<div class="control-panel" id="left-panel">
<div class="btn up-btn" data-action="forward">前进</div>
<div class="btn left-btn" data-action="left">向左</div>
<div class="btn down-btn" data-action="backward">后退</div>
<div class="btn right-btn" data-action="right">向右</div>
</div>
<div class="control-panel" id="right-panel">
<div class="btn rotL-btn" data-action="rotL">左转</div>
<div class="btn up-btn" data-action="up">上升</div>
<div class="btn rotR-btn" data-action="rotR">右转</div>
<div class="btn down-btn" data-action="down">下降</div>
</div>
<script type="module">
import * as THREE from 'three';
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
const originalFetch = window.fetch;
window.fetch = async function(resource, init) {
const url = typeof resource === 'string' ? resource : resource.url;
if (url && url.includes('.splat')) {
const cacheName = '3dgs-model-cache-v2';
const cache = await caches.open(cacheName);
let response = await cache.match(url);
if (!response) {
console.log("downloading");
response = await originalFetch(resource, init);
if (response.ok) {
cache.put(url, response.clone());
console.log("cache saved");
}
} else {
console.log("cache hit");
}
return response;
}
return originalFetch(resource, init);
};
const viewer = new GaussianSplats3D.Viewer({
'initialCameraPosition': [-1.21046, -0.74130, -0.07261], //修改成适配自己模型的参数。参数可在本地渲染模型的过程中使用快捷键 Ctrl + Shift + I 查看
'initialCameraLookAt': [3.37537, -3.75399, 24.30517],
'cameraUp': [0, -1, 0],
'sharedMemoryForWorkers': false,
'rootElement': document.getElementById('canvas-container')
});
const tencentCosUrl = 'https://你的存储桶地址.myqcloud.com/classroom.splat';
viewer.addSplatScene(tencentCosUrl, {
'showLoadingUI': true
}).then(() => {
viewer.start();
}).catch(err => {
alert("加载模型失败:" + err);
});
let moveInterval = null;
let activeAction = null;
const speed = 0.1;
const rotSpeed = 0.05;
function doMove() {
if(!viewer.camera || !viewer.controls) return;
const cam = viewer.camera;
const target = viewer.controls.target;
const dir = new THREE.Vector3();
cam.getWorldDirection(dir);
const right = new THREE.Vector3().crossVectors(cam.up, dir).normalize();
switch(activeAction) {
case 'forward':
cam.position.addScaledVector(dir, speed);
target.addScaledVector(dir, speed);
break;
case 'backward':
cam.position.addScaledVector(dir, -speed);
target.addScaledVector(dir, -speed);
break;
case 'left':
cam.position.addScaledVector(right, speed);
target.addScaledVector(right, speed);
break;
case 'right':
cam.position.addScaledVector(right, -speed);
target.addScaledVector(right, -speed);
break;
case 'up':
cam.position.y += speed; target.y += speed;
break;
case 'down':
cam.position.y -= speed; target.y -= speed;
break;
case 'rotL':
case 'rotR':
const angle = (activeAction === 'rotL') ? rotSpeed : -rotSpeed;
const offset = target.clone().sub(cam.position);
offset.applyAxisAngle(cam.up, angle);
target.copy(cam.position).add(offset);
break;
}
}
document.querySelectorAll('.btn').forEach(btn => {
const startEvent = (e) => {
e.preventDefault();
activeAction = btn.getAttribute('data-action');
if(!moveInterval) moveInterval = setInterval(doMove, 30);
};
const stopEvent = (e) => {
e.preventDefault();
activeAction = null;
if(moveInterval) { clearInterval(moveInterval); moveInterval = null; }
};
btn.addEventListener('mousedown', startEvent);
btn.addEventListener('mouseup', stopEvent);
btn.addEventListener('mouseleave', stopEvent);
btn.addEventListener('touchstart', startEvent, {passive: false});
btn.addEventListener('touchend', stopEvent, {passive: false});
});
</script>
</body>
</html>
由于网站是可公开访问的,安全起见,需要对对象存储服务进行设置。
- 进入当前存储桶。点击“安全管理” → “防盗链设置”。开启防盗链,类型选择白名单模式,拒绝空 Referer。Referer 填写模型将要展示的站点的域名。
- 点击“跨域访问 CORS 设置”,“来源”填写自己的真实生产域名,操作选择 “GET”。
- 在腾讯云顶栏右侧点击“费用”,进入“费用中心”页面后,点击费用预警并设置预警阈值。根据需求设置。
当然,上述设置很难作为真正的访问控制。攻击者可以通过伪造 Referer 请求绕过防盗链。更安全的策略应该是将桶改成私有读写,然后通过Cloudflare Workers 验证访问者是否允许查看页面,并生成短时有效的 COS 预签名 URL,有效期设置为数分钟。此外,需要对签发接口做 IP,会话和请求频率限制,或使用腾讯云 CDN 回源鉴权。不过,要将上述方案的完整实现全部写明,确实是不小的工程。行笔至此,全文已超万字,因此本文暂不展开实现细节,只列出可选的安全方案。
最后,附上几张 3DGS 成品图片。








