0%

最近在学吴恩达的《深度学习专项》(Deep Learning Specialization)。为了让学习更有效率(顺便有一些博文上的产出),我准备写一些学习笔记。笔记的内容比较简单,没有什么原创性的内容,主要是对课堂的知识进行梳理(这些文章的标题虽然叫”笔记“,但根据我之前的分类,这些文章由于原创性较低,被划分在”知识记录“里)。如果读者也在学这门课的话,可以对照我总结出来的知识,查缺补漏。之后几节课有课后作业,我会在笔记里讲解我的编程思路,给读者一些编程上的启发。

文章中的正文主要是对课堂内容的总结。引用里的内容和每篇笔记的总结是我个人的观点或评论。

什么是神经网络

我们把一个有输入有输出的计算单元叫做“神经元”。神经元可以简单地理解成一个线性函数。比如要预测房价和房屋面积的关系,我们可以近似地用一个线性函数去拟合。这个函数就是一个神经元。

事实上,一个神经元不仅包含一个线性函数,还包括一个激活函数。这里提到了激活函数 ReLU 的概念,其具体内容应该会在后面介绍。

神经元的堆叠,构成了神经网络,如下图所示。

在用一个神经元来表示房价和房屋面积的关系时,神经元的输入是房屋面积,输出是房价。而用多层神经元时,每个神经元的意义可能都不一样。比如中间的神经元可能会根据输入的邮政编码、地址特征,输出一个表示房屋地段的中间特征。在神经网络中,这些特征都是自动生成的(意味着我们只需要管理神经网络的输入和输出,而不用指定中间的特征,也不用理解它们究竟有没有实际意义)。

以前的一些机器学习要手动设置特征。而神经网络这种自动生成特征的性质,是其成功的原因之一。

用神经网络做监督学习

要理解监督学习,其实应该要对比无监督学习。本节实际上是介绍了监督学习的几个例子。

常见的神经网络有三类:

  1. 标准神经网络(即全连接网络)可以用于房屋分类、广告分类问题。(这些问题一般输入是结构化的)
  2. 卷积神经网络(CNN)一般用于图像相关的问题,比如图片猫狗分类,自动驾驶中识别其他车辆的位置。
  3. 循环神经网络(RNN)一般用于处理有时序的序列数据,比如和声音、文字有关的应用都需要RNN。

结构化数据,就是所有其数据项都是人能理解的(房子的面积、价格)。对比来看,无结构化的数据的具体含义是无法直接解释的,比如图像每一个像素值、声音某时刻的频率和响度、某一个文字/单词。

为什么最近深度学习“起飞”了?

这张图足以解释深度学习腾飞的原因。随着数据量的增加,所有方法都有性能的上限。而对于神经网络来说,结构越复杂的神经网络,其性能上限越高。复杂的神经网络(深度学习方法)在海量数据不断产生的今天更具优势。

光有大量的数据,没有使用数据的方法是不够的。总结来看,深度学习在近几年得到发展的原因有下:

  • 互联网的发展使得数字数据大量增长。
  • GPU等计算设备使得处理数据的硬件变强。
  • 深度学习的算法不断更新迭代,从软件层面上加快了数据处理。(比如激活函数的改进,从sigmoid到ReLU)

深度学习本质上还是以实验为主。计算能力上来了,研究人员做实验做得快了,各种各样的深度学习的应用也就出来了。各种应用又鼓舞着更多人参与深度学习研究。也就是说,是计算能力的提升使得近年来深度学习进入了良好的正反馈循环中。

总结

第一周的课没有什么深奥的内容,主要是给对深度学习不太熟悉的同学们介绍了下背景知识。

在我看来,这周的课需要记住的东西有:

  • 神经元有输入和输出的计算单元。神经元堆叠成了神经网络。
  • 大致有三种不同类型的神经网络,适用于不同的任务。
  • 神经网络的性能随其规模和数据量而增长。
  • 计算效率的提高使深度学习近期得到飞速发展。

PyTorch 自定义算子教程:两种方法实现加法算子(附LibTorch Windows环境配置教程)

我们都知道,PyTorch做卷积等底层运算时,都是用C++实现的。有时,我们再怎么去调用PyTorch定义好的算子,也无法满足我们的需求。这时,我们就要考虑用C++自定义一个PyTorch的算子了。

PyTorch提供了两种添加C++算子的方法:编译动态库并嵌入TorchScript[1]、用PyTorch的C++拓展接口[2]。前者适合导入独立的C++项目,后者需要用PyTorch的API设置编译信息,只适合小型C++项目,更适合于把新算子共享给他人的情况。由于我还没有用过torch的C++接口,这里先用第一种方法写一套独立的算子实现示例,跑通整个流程,再基于同一份代码,用第二种方法实现一次,以全方位地介绍PyTorch自定义算子的方法。

前置准备:

  • 装好了CMake
  • 装好了PyTorch
  • 装好了OpenCV
  • 看得懂C++、Python

知识点预览:

  • 如何配置LibTorch
  • 第一个Torch C++程序
  • 如何自己写简单的CMake
  • 如何用Visual Studio写CMake项目
  • 如何编译使用简单的动态库
  • 如何用两种方法实现PyTorch自定义算子
  • 如何用setuptools自动编译C++源代码

(以上是我写这篇文章之前还不会的东西。)

  • 如何用PyTest做单元测试

参考教程

[1] 添加TorchScript拓展 https://pytorch.org/tutorials/advanced/torch_script_custom_ops.html

[2] PyTorch的C++拓展 https://pytorch.org/tutorials/advanced/cpp_extension.html

[3] 安装LibTorch https://pytorch.org/cppdocs/installing.html

[4] VS CMake https://docs.microsoft.com/zh-cn/cpp/build/cmake-projects-in-visual-studio?view=msvc-170&viewFallbackFrom=vs-2019

配置 LibTorch 开发环境

我们这个项目是使用CMake开发的,理论上任何平台都能使用。我是在Windows上测试的,理论上Windows上碰到的毛病会多一些,Linux上可能直接用就没问题了。

对于我们这个CMake项目来说,成功添加路径,使得find_package(Torch)(找到Torch的CMake配置)不报错就算配置环境成功。当然,貌似由于Torch依赖于OpenCV,找OpenCV包也得成功才行。

参考教程是[3],但对于像我一样什么都不懂的新手来说,由于CMake有些东西要配置,这篇官方教程还不太够用。

下载 LibTorch

想用PyTorch的C++相关内容的话,要先去下载LibTorch库。

在获取PyTorch的Python版本下载命令处,可以找到LibTorch的安装链接:

和装PyTorch Python版的时候类似,选好自己的版本,之后点击某个链接下载就行。第一个链接是Release版,第二个是Debug版。由于我是编程高手,不要调试,所以直接选择了Release版。建议大家去下Debug版方便随时调试。

添加环境变量

下一步要把LibTorch的动态库所在目录加入环境变量中,以使程序运行时能够找得到依赖的动态库(编译是没问题的)。

xxxxxxxx\libtorch\lib这个目录添加进环境变量即可。

如果是在Windows上,添加环境变量时有一个细节要注意:

相信90%的人装PyTorch前都是把Cuda装好了的。在添加LibTorch的动态库目录时一定要注意,要把这个路径移到Cuda路径的上面。详细原因见FAQ

Hello LibTorch

接下来我们要用一个能调试CMake程序的环境来完成第一个C++ LibTorch程序。

创建一个崭新的文件夹,在里面添加一个CMakeLists.txt:

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.1 FATAL_ERROR)
project(equi_conv)

find_package(Torch REQUIRED)
find_package(OpenCV REQUIRED)

add_executable(equi_conv op.cpp)
target_compile_features(equi_conv PRIVATE cxx_std_14)
target_link_libraries(equi_conv "${TORCH_LIBRARIES}")
target_link_libraries(equi_conv opencv_core opencv_imgproc)

里面的equi_conv可以换成你喜欢的项目名。我使用的项目名是equi_conv,这个名称会在后面多次出现。理论上我显示equi_conv的地方显示的应该是你自己的项目名。

注意! 一般情况下CMake是找不到Torch和OpenCV的,要手动设置CMake Configure附加命令中的Torch_DIROpenCV_DIR这两个参数,比如我的附加命令是

1
-D Torch_DIR="D:/Download/libtorch-win-shared-with-deps-1.11.0+cu113/libtorch/share/cmake/Torch" -D OpenCV_DIR="D:/OpenCV/opencv/build"

Torch_DIR"xxxxxxxx/libtorch/share/cmake/Torch,OpenCV_DIR大约是xxxxxxxx/opencv/build。每个人的具体路径可能不一样,只要记住,这两个路径里都得是包含了.cmake文件的。根据编程环境的不同,设置这两个CMake参数的位置也不同,详见后文。

官方教程[3]给了一种很骚的提供路径的方法:-DCMAKE_PREFIX_PATH="$(python -c 'import torch.utils; print(torch.utils.cmake_prefix_path)')"。这个命令是调用Python脚本以添加PyTorch默认的CMake搜索目录。但是这个命令有一些问题:1) 当前命令行环境里不一定能正确调用Python及访问torch库(比如PyTorch是用conda装的,而当前环境不是对应的conda环境);2) 我们下载的libtorch似乎难以对得上PyTorch包里默认的libtorch路径。这行命令似乎仅适用于处于正确Python环境下,把libtorch装到了/libtorch目录下的Linux系统。为了命令的兼容性,我们不用这么骚的操作,老老实实自己设置LibTorch目录和OpenCV目录。

再写一个叫op.cpp的C++源文件。

1
2
3
4
5
6
7
8
#include <torch/torch.h>
#include <iostream>

int main() {
torch::Tensor tensor = torch::rand({ 2, 3 });
std::cout << tensor << std::endl;
return 0;
}

如果你是高手,可以不去配环境,直接手敲CMake命令。但为了方便,接下来我们还是准备调试运行这个程序。配置CMake调试环境有很多方法,这里先给一个Windows上Visual Studio的方案[4]

准备好上面那个CMakeLists.txt后,用VS打开这个CMake文件(相信大家的VS都是2017版本以上的,旧版本是没有CMake的功能的~):

如果文件没写错VS会自动配置(Configure)CMake。在工具栏中可以手动中断或开始CMake的配置。

还可以点击上面的“{PROJECT_NAME}的CMake设置”来设置CMake命令中要用的参数(比如-D参数)

注意,一开始CMake只有Debug版的配置,可以点左上角的加号手动加一个Release版的配置。

同时,如图中所示,xxx_DIR应该卸载CMake命令参数里面。

配置好后去上面的工具栏点击”生成-全部生成”就可以把程序编译好了。接下来按熟悉的F5就可以运行程序了。

再介绍一个VSCode的CMake编程环境,这个基本上是全平台通用的。不过同样,我还是在Windows上测试的,以Windows上的配置为主。

通过搜索”Windows CMake VSCode cl 配置”等关键词,我搜索到了一篇很好的教程,我是照着这篇教程配的环境。如果是Linux的话,换一下编译器应该就能拿过来用了。

为了添加-D等配置参数,可以用ctrl+,打开设置,修改工作区设置里的CMake Configure命令:

如果一切正常,程序会输出随机张量的内容。

LibTorch A+B

C++ 侧

修改op.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <torch/torch.h>
#include <opencv2/core/core.hpp>

torch::Tensor my_add(torch::Tensor t1, torch::Tensor t2)
{
assert(t1.size(0) == t2.size(0));
assert(t1.size(1) == t2.size(1));
cv::Mat m1(t1.size(0), t1.size(1), CV_32FC3, t1.data_ptr<float>());
cv::Mat m2(t1.size(0), t1.size(1), CV_32FC3, t2.data_ptr<float>());

cv::Mat res = m1 + m2;

torch::Tensor output = torch::from_blob(res.ptr<float>(), { t1.size(0), t1.size(1), 3});
return output.clone();
}

TORCH_LIBRARY(my_ops, m)
{
m.def("my_add", my_add);
}

我们要实现一个新PyTorch算子my_add,该实现函数先把两个PyTorch Tensor转换成OpenCV Mat,用Mat做加法,再把Mat转回Tensor。整个代码非常易懂,哪怕对LibTorch和OpenCV的语法不熟,也基本猜得出每行代码的作用。

1
2
#include <torch/torch.h>
#include <opencv2/core/core.hpp>

一开始,先包含LibTorch、OpenCV的头文件。

1
torch::Tensor my_add(torch::Tensor t1, torch::Tensor t2)

我们要实现的是一个PyTorch的加法,因此实现函数中所有的张量类型都是torch::Tensor。加法输入是两个量,输出是一个量,因此最后的函数头要这样写。

1
2
assert(t1.size(0) == t2.size(0));
assert(t1.size(1) == t2.size(1));

做为严谨的程序员,我们要对输入的Tensor做一定的检查(实际上这两个检查还不够,由于我们默认输入图像的通道是3,还应该检查一下通道数。但这样检查下去可能会没完没了了,这里仅仅是提醒大家要养成良好的编程习惯)(其实是我写了两行就懒得写下去了)。

1
2
cv::Mat m1(t1.size(0), t1.size(1), CV_32FC3, t1.data_ptr<float>());
cv::Mat m2(t1.size(0), t1.size(1), CV_32FC3, t2.data_ptr<float>());

这两行是用Tensor构造Mat。从这两行代码中,可以学到两点:1)可以通过tensor.data_ptr<float>来获取Tensor存储数据的指针;2)不同框架下的数据结构互转时一般是传指针,再传shape。

OpenCV这里有一点点特殊。OpenCV的Mat是二维的,要维护一个H-W-C(高-宽-通道)的数据,需要传一个基础数据类型CV_32FC3,即3通道浮点数。

从代码中可以猜出来,tensor.size(i)可以获取Tensor第i维的长度。

1
cv::Mat res = m1 + m2;

不用猜都知道这是调用了Mat的加法。

1
torch::Tensor output = torch::from_blob(res.ptr<float>(), { t1.size(0), t1.size(1), 3});

这一行是Mat转Tensor,同样是传了数据指针和张量形状。

这里第二个参数是个叫at::IntArrayRef的类型的。这个类型会用在Tensor的shape上。该类型的最简单的初始化方式就是用大括号把值框进去,就像Python里用方括号或圆括号传List和Set一样。

相比生成OpenCV Mat,这里没有传数据类型。原因如前文所述,应该是由于OpenCV的数据类型里包含了维度信息,所以OpenCV的Mat构造时要额外传这个信息。

1
return output.clone();

最后返回的是tensor.clone()。官方教程里说,用指针创建Tensor时会复用原来的指针,而不会新申请内存。函数结束后,Mat里的资源会释放,等于说这个用Mat创建出的Tensor也失效了。因此要clone()一下,让数据在函数结束后依然存在。

1
2
3
4
TORCH_LIBRARY(my_ops, m)
{
m.def("my_add", my_add);
}

最后调用API把C++函数绑定到Python上,现在可以不用追究这些代码的具体原理,只要知道这样写Python就可以访问到my_add了。

这里可以改动的内容其实有两处:算子的域my_ops,算子名/函数名my_add。前面那个my_ops在PyTorch的某些地方会用到,这里我们先不管,随便取一个名字即可。

现在我们要编译的是一个包含一个函数的动态库,而不是一个包含main的应用程序了。因此,我们要修改一下CMakeLists.txt中的编译选项:

1
2
3
4
5
6
7
8
9
10
cmake_minimum_required(VERSION 3.1 FATAL_ERROR)
project(equi_conv)

find_package(Torch REQUIRED)
find_package(OpenCV REQUIRED)

add_library(equi_conv SHARED op.cpp)
target_compile_features(equi_conv PRIVATE cxx_std_14)
target_link_libraries(equi_conv "${TORCH_LIBRARIES}")
target_link_libraries(equi_conv opencv_core opencv_imgproc)

其实就改了一行:add_library(equi_conv SHARED op.cpp),这样可以把编译目标变成一个动态库。

代码没错的话,重新Configure和Generate后动态库就编译好了。

Python 侧

我们写一个单元测试Python脚本来测试一下我们的算子能否在PyTorch里成功运行:

1
2
3
4
5
6
7
8
9
10
11
12
import torch

lib_path = r"D:\Repo\equi_conv\EquiConv\out\build\x64-Release\equi_conv.dll"
torch.ops.load_library(lib_path)


def test_add():
a = torch.rand([10, 10, 3])
b = torch.rand([10, 10, 3])
c = torch.ops.my_ops.my_add(a, b)
d = a + b
assert torch.allclose(c, d)

再一次,为了体现我们编程时的严谨性,我们使用pytest来测试这个脚本。pip install pytest就可以轻松安装好这个Python单元测试工具。但如果你实在太懒了,不想下pytest,就得在后面补一行test_add()手动调用一下这个函数。

1
2
lib_path = r"D:\Repo\equi_conv\EquiConv\out\build\x64-Release\equi_conv.dll"
torch.ops.load_library(lib_path)

import torch就不说了。这两行代码是调用PyTorch的API来读取我们刚刚编译出来的动态库。我们这里只需要把动态库路径改成自己的就好,别的都不用改。

1
2
3
4
5
6
def test_add():
a = torch.rand([10, 10, 3])
b = torch.rand([10, 10, 3])
c = torch.ops.my_ops.my_add(a, b)
d = a + b
assert torch.allclose(c, d)

后面这些代码就是实际单元测试的代码里,代码非常简单:生成两个随机tensor,比较一下我们的加法和PyTorch自己的加法是否结果一致。

值得注意的是,用torch.ops.my_ops.my_add可以调用我们刚刚那个C++函数。前面的torch.ops都是写死的,后面的my_add是我们自己定义的函数名。而my_ops,则是我们刚刚调API时填的“算子域”了。算子域在注册Python符号表的时候还会用到,这里不用管那么多,把算子域理解成一个命名空间,一个防止算子命名冲突的东西即可。

torch.allclose可以简单地理解为一个要求两个Tensor所有值都几乎相等的比较函数。

在该文件夹下运行命令pytest,屏幕上显示绿色的1 passed xxxxxxxxxx即说明单元测试成功运行。

至此,我们算是成功在Python里调用了一个C++写的算子。只需要写上torch.ops.my_ops.my_add`,我们就能够在任何地方(比如模型的forward函数)调用我们的算子。聪明的人看到这里,已经学会随心所欲地在PyTorch里嵌入自己的高效率的C++算子了。

配好环境,搭好框架后,我们自己实现算子倒是非常舒服。问题是,如果我们要把这些算子给别人使用的话,要么是给别人源代码,让别人自己配置LibTorch编译环境;要么是把所有Torch版本数 * Cuda版本数 * 操作系统数这么多个动态库给预编译出来。

要是能抛掉LibTorch,让有PyTorch和Cuda环境的用户自己编译源代码,似乎一个平衡开发者体验和用户体验的选择。所以,这里再介绍之前讲过的第二种添加算子的方法:直接在PyTorch里添加C++拓展。

PyTorch Extension A+B

用Python的setuptools也可以编译一些C++项目但,由于其头文件目录、依赖的库目录这些编译选项需要手动设置,setuptools仅适用于编译比较简单的C++项目。

在同文件夹中,编写以下的setup.py文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from setuptools import setup
from torch.utils import cpp_extension

include_dirs = [r'D:\OpenCV\opencv\build\include']
library_dirs = [r'D:\OpenCV\opencv\build\x64\vc15\lib']
libraries = [r'opencv_world452']

setup(name='my_add',
ext_modules=[
cpp_extension.CppExtension('my_ops', ['op2.cpp'],
include_dirs=include_dirs,
library_dirs=library_dirs,
libraries=libraries)
],
cmdclass={'build_ext': cpp_extension.BuildExtension})

在这个源文件中,要改的就是以下三个路径(代码块中显示的是我的路径):

1
2
3
include_dirs = [r'D:\OpenCV\opencv\build\include']
library_dirs = [r'D:\OpenCV\opencv\build\x64\vc15\lib']
libraries = [r'opencv_world452']

这三个路径用于配置OpenCV的编译选项,分别表示OpenCV的包含目录(头文件目录)、静态库目录、静态库名。用Visual Studio导入过第三方库的,肯定对这三个选项不陌生。

如果是在 Linux 上,前两个路径大概是”/usr/local/include/opencv2”, “/usr/local/lib” 。最后的库名填写opencv_core即可。

至于PyTorch相关的编译选项,我们不需要手动设置。这是因为我们用了PyTorch封装的添加C++拓展接口,PyTorch有关的路径已经被填好了。

1
2
3
4
5
6
7
8
setup(name='my_add',
ext_modules=[
cpp_extension.CppExtension('my_ops', ['op2.cpp'],
include_dirs=include_dirs,
library_dirs=library_dirs,
libraries=libraries)
],
cmdclass={'build_ext': cpp_extension.BuildExtension})

在调用setup时,name是整个项目的名字,可以随便取。my_ops和刚刚一样,是命名空间的名字,我们还是保持my_ops这个名字。op2.cpp就是要编译的源文件了,这里我们待会再讨论。剩下的参数这些传进去就行了。

我们再在op.cpp的基础上新建一个新的C++源文件op2.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <torch/torch.h>
#include <opencv2/core/core.hpp>

torch::Tensor my_add(torch::Tensor t1, torch::Tensor t2)
{
assert(t1.size(0) == t2.size(0));
assert(t1.size(1) == t2.size(1));
cv::Mat m1(t1.size(0), t1.size(1), CV_32FC3, t1.data_ptr<float>());
cv::Mat m2(t1.size(0), t1.size(1), CV_32FC3, t2.data_ptr<float>());

cv::Mat res = m1 + m2;

torch::Tensor output = torch::from_blob(res.ptr<float>(), {t1.size(0), t1.size(1), 3});
return output.clone();
}

PYBIND11_MODULE(my_ops, m)
{
m.def("my_add", my_add);
}

其实修改的就是这一行TORCH_LIBRARY(my_ops, m)->PYBIND11_MODULE(my_ops, m),没有调用TorchScript的绑定接口,而是直接用Pybind绑定了C++函数。

接下来,在当前文件夹下运行命令python setup.py install即可编译刚刚的C++源文件了。成功的话大概会有Finished processing dependencies for my-add==0.0.0这样的提示。

编译结束后,我们在原来test_add.py的基础上添加一些单元测试,看看用这种新方法编译完C++拓展后怎么调用C++函数。

1
2
3
4
5
6
7
def test_add2():
import my_ops
a = torch.rand([10, 10, 3])
b = torch.rand([10, 10, 3])
c = my_ops.my_add(a, b)
d = a + b
assert torch.allclose(c, d)

由于我们刚刚编译了一个命名空间为my_ops的包,我们可以用import my_ops导入这个刚刚编译好的库了。现在调用C++函数的方法变成了my_ops.my_add,其他地方都没有变化。

运行pytest test_add.py::test_add2可以单独测试这一个函数。当然懒的话直接pytest可以把刚刚那个测试和这个测试一起做一遍。单元测试通过就说明我们成功运行了C++拓展。

事实上,这种安装方式还是不够友好。由于我们用到了OpenCV,OpenCV的库路径还是要手动设置。这种安装方式只有在除PyTorch本身外不需要任何第三方库时比较友好。不然的话要么让用户自己手动设置路径,要么在代码库里引用别的开源库,再一个一个重写路径。大型项目还是用CMake等编译系统来编译比较友好。

总结

在这篇文章中,我介绍了两种在PyTorch里调用C++新算子的方法。只要看懂了这篇文章,就算是彻底打通了PyTorch与C++的桥梁,以后写代码可以专注于C++算子的实现及PyTorch对算子的封装,剩下的绑定算子的内容直接套这个模板就行。

两种算子实现方法的区别主要在于编译选项的设置上和用户在编译算子的体验上。应根据项目的实际情况选择一种方案。

这篇文章强行调用OpenCV实现了Tensor加法,看上去是多此一举,实际上这是为了展示如何在添加自定义算子时使用第三方库。但为了简化他人编译的过程,实际实现算子时最好只用原本的PyTorch API。

FAQ

运行LibTorch的示例程序,无法定位程序输入点 xxxxx 于动态链接库 xxxxx

这个问题找了我老半天,就找到2~3个相关的答案,全是治标不治本的方法。

有人说,是动态库路径的问题。我测试了一下,直接运行编译好的程序会报错,但是把程序放到LibTorch的动态库目录下就不会报错。我已经隐隐约约地感觉到,不是动态库找不到,而是动态库路径的优先顺序出了问题。

果不其然,最后我在这篇文章里找到了问题的真正原因:Cuda的动态库和LibTorch的冲突了(PyTorch和Cuda要背大锅)。那篇文章中暴力删掉了Cuda的动态库,但是温柔的我们绝对不要这样做。按照前面章节的内容,调整LibTorch与Cuda的路径优先级即可。

貌似官方教程提到了类似的错误。这里再提供一种可能的解决问题的思路(反正我没试)。

[WinError 126] 找不到指定的模块

这个问题说明Python的PyTorch库版本和下载的LibTorch C++版本不一致。用pip show torch查看当前的PyTorch版本,去重新下载对应的LibTorch即可。

OSError: xxx Undefined symbol (Linux)

要把 LibTorch 的动态库加入 LD_LIBRARY_PATH 里。

有关博客“学习”分类下子类别的说明

貌似之前说明过一次,这里再整理一遍。

  • 工具用法指南:几乎没有技术含量的,把下载安装过程的踩坑过程原封不动地讲一遍。
  • 知识记录:对现有成体系知识的描述,尤其会写教科书、公开课上的知识,较少我个人的见解。
  • 知识整理:对某一工具、知识、技术的说明,主要以我个人的见解、整理为主。

另外,“学习”类别和“记录”类别挺容易混淆的。这里我再做个规定:“记录”以具体的任务为导向,比如先有要写的作业、要看的论文、要做的项目、规划好的旅游计划,再对这些事情进行描述。而“学习”中包含的文章,更多是一种主观的,以学到东西为目的而写的文章。如果我看了一篇论文,只写论文的内容的话,会分到“记录”里;如果我想调研一个主题的文章,会把调研结果放到“学习-笔记”里;如果我看了很多论文,有了原创性非常强的一篇描述知识的文章,会放到“知识分享”(未来的“创作-知识”)里。

由于这个学期没有返校,我变懒了不少。明明有不少可以写的东西,却没有去写。等学期结束了有时间了我会好好补写一些博客。

这几天,我在赶一个高性能计算的大作业。题目要求优化一段代码,使程序的运行时间尽可能短。大作业本来是一个令人烦躁忧虑,头皮发麻的事物,但在deadline的紧逼之下,我仿佛按下了大脑的启动按钮,火力全开地应对起这个大作业来。于是,我的漫长的编程时间就开始了。——看到这里,如何你对编程不是非常了解,可能完全没有读下去的兴致。但我保证我会用外行人也能看懂的方式,来描述我这次有趣的写大作业经历。

再具体地讲一下我的大作业要求。我的大作业题目是代码优化,跑代码花费的时间是评价成绩的唯一指标。当然,代码的正确性不能受到影响,你不能让程序刚进去就关掉。代码跑得越快,分数越高。如果代码速度是原来的2倍,则可以拿到60分及格。如果达到原来的2.5,3,3.5,4倍,则可以分别拿到80,90, 95, 100分。成绩评判标准唯一且清晰。

我是作业截止的最后4天开始看这个大作业的。看完这个评分标准,我心里先是一乐:“哈哈,终于有一个评分透明的大作业了,写论文什么的成绩太容易受老师主观评价影响了。”接着,我又发现事情有些不太对:“既然老师敢给这么高的分数,说明代码想优化到3倍或者4倍是很困难的。我的时间这么少,恐怕只能拿个80分吧。”我也没再想下去,反正代码能优化多少就优化多少,也没有那种先定目标再开始干活的必要。

我的编程任务正式开始了。准确来说,我做的不是编程,是合理而优雅地修改老师给的代码,让这个代码运行速度更快一些,而不改变程序本身的意思。要打比方的话,我是一个拿着手术刀的医生,我需要精准地切掉病变部位,还病人一个更好的身体。比较幸运的是,我的工作可以反复调试,代码出了问题可以重新修改,而不需要担心造成什么破坏性的后果。

代码的功能是计算化学反应的一些参数。这段代码产生的程序不会产生任何花哨的网页、按钮,只会在默默地运行数十秒中后,冷冰冰地在控制台上输出一行数学计算的结果。运行这段代码就好像把一个优等生关进一个房间,让他把数学卷子全做出来再离开一样。只不过程序在输入的参数完全相同的情况下,每次都会执行一模一样的操作,最终得到完全相同,完全正确的结果。

这段代码可谓是又臭又长。丝毫没有注释,一个文件里的代码(代码分布在多个文件中)全是看不懂的化学常量,另一个文件的代码全是看似毫无逻辑的计算步骤。在浏览过代码,手足无措数秒钟后,我迅速转换了思维:“化学反应的代码归根结底就是计算一个很长的公式。我没有必要看懂为什么这么做,我只需要知道哪些加减乘除运算可以被我优化就行了。”我的这种冷静能力与思维跳转能力非常惊人。

这个大作业中,我学到了查看代码性能瓶颈的方法。经过检测,代码最耗时的部分竟然是一个exp()自然指数运算。自然指数本身是一个很容易理解的函数,高中数学就讲过这个函数的性质。这个函数在数学上很好表达,在程序中计算起来却非常麻烦。因为这个函数的值往往是一个无限不循环数,而程序只能进行有限的计算。程序只能通过多次的加法、乘法来得到一个十分近似的结果。程序总计进行了3亿次自然指数运算,代码性能瓶颈出现在这确实也情有可原。

程序中的指数运算,需要调用标准库。标准库是高级程序员们反复锤炼,被无数人反复验证的代码。这个自然指数运算对我来说是根本不可能修改的。看到这个情况,我心都凉了半截。

虽然一上来就碰到如此困难的情况,但我再次调整了心态。指数运算耗费了三成的时间,还有七成的运行时间可以被优化。我把目光又放到了其他运算速度较慢的代码上。凭借多年的高级语言(比较抽象、接近自然语言的程序语言)编程经验,我意气风发,大刀阔斧地对代码进行优化。我主要对代码进行了预处理、循环合并这两类优化。预处理就是把一些程序中不会变动的常量提前算好,避免每次重新计算。就好像你提前买一箱抽纸,这样几个月都不用再去买纸一样。循环合并就是把条件一样,需要反复做的事情,再每个“反复”中一起做掉。比如让你去测全校同学的身高体重,量身高和量体重的仪器摆在一起,这里假设两台仪器不能同时工作。你不能让同学挨个测完身高,回教室休息一下,再一个一个回来量体重。每个人量身高体重的时间虽然没变,但来回教室、排队等待的时间浪费了。最好的方法是每个人先量身高,再量体重。如果你懂编程,你或许能理解,这里的来回教室时间就是CPU从内存中读数据的时间,排队时间可以看成循环变量、控制流耗费的时间,在一个循环里做尽可能多的事能减小时间开销。

以上两类优化是非常基础的优化。经过优化后,程序运行时间从26秒到了17秒。很可惜,速度还没有达到2倍,我的成绩连及格也没有。我反复查看了其他部分的代码,绞尽脑汁也没有想出哪里可以优化。于是,我只好把目光再次放到了开始的指数运算上。

经过观察,我发现指数运算是一批一批做的,也就是一次会对多个数据依次执行步骤相同的指数运算,而标准库只能每次对一个数据进行指数运算。这里面有没有可以利用的空间呢?我凭借着十多年与搜索引擎打交道的经验,总算找到了一个比较厉害的数学运算库。里面有一种对批量数据进行指数运算的函数。我满怀期望地用这种”高级“函数替换了原来的函数。结果非常令人惊喜:程序的执行时间从17秒降到了12~13秒,速度整体变成了原来的2倍,我总算及格了。

开心之余,我总感觉自己忘了些什么事。仔细回忆了一下,代码优化不仅要快,原来程序的输出结果还不能被更改。我还没有验证新代码的正确性呢!我赶快把新代码和旧代码的结果进行了比较,发现新代码的输出结果发生了变化!

我改动了那么多处,究竟是哪一步出错了啊?!为了找出代码中的BUG,我采用了古老而有效的控制变量法。我把旧代码粘贴了回去,一段一段地把代码更新成新代码。如果某一次更新后运行结果有问题,就说明这段代码有问题。我顺利地找出了不少的低级错误。可是,我最不想碰到的情况发生了——

我发现指数运算的那整段代码中存在问题。我尝试地把指数运算的那一行改回了旧版本的代码,输出结果就正常了。也就是说,这种优化的指数运算会导致结果不正确。

我又慌了,心想指数运算这道坎可能就是过不去了。但我内心中突然涌现出的自信告诉我,我的新代码没有错误。这个新的指数运算函数是intel公司写的,如果有问题,只可能是他们有问题。代码的运行结果虽然改变了,但是代码不一定有错——这两句话并不是矛盾的。在精度较高的数学计算中,如果小数点后面好多位出现了偏差,只能算是结果有误差,不能说结果错误。况且原来的代码也只是对数学计算结果的一种近似,你怎么能保证原来的代码就离正确的结果更近呢?给你两块手表,两者差了几秒,你能知道哪块手表是正确的时间吗?保证这样的心理,我对新代码输出结果的误差进行了计算。

经检验,新代码和源代码的相对误差在小数点后6位,也就是0.0001%这个数量级。老师可没有强调结果要完全相同,或者误差保持在什么范围内。从道理上来看,只要保证代码整体的正确性就行。在强烈的自我暗示下,我接受了代码输出结果不完全相同,但我却是正确的这一事实。

如前面所述,我已经尽可能地在其他地方优化代码,就目前而言,这份代码对我来说是最优的。第一天天色已晚,我选择偃旗息鼓,明日再战。

第二天十点,烈日当空,天朗气清。我再次开始着手优化代码。经过缜密的分析,我觉得我缺少部分的知识,我需要从别的角度入手,用一些我不太熟悉的方法优化代码。

程序的运行可以分成串行和并行。串行的概念非常简单,我们人的大脑就是串行的。你不能说我左半边脑子在浏览选项题,右边的大脑跑去想填空题去了。并行就是多个串行,可以理解成多个人合作做一件事。写论文时,你写正文,我写摘要,我们同时开始写,这就是一种并行。显然,N个人干活,必然不能让事情的快N倍。因为人与人之间的交流存在着极大的效率浪费。如若不然,为什么每个企业要设置那么复杂的管理体系呢?并行程序也是如此,在硬件支持的情况下,程序并行可以提高速度,但也有一定的资源损耗。

我学过并行的知识,了解并行的概念,但没有并行编程的经验。于是,我只好以”C++ 并行编程”为搜索词去搜索有关信息。很快,我就搜到了一个满意的答案:有一种叫OpenMP的并行编程API,只需要在程序里加一些代码,就能把串行的程序变成并行的程序。但是,要是只加少量代码的话,只能并行执行循环结构,且只有对重复次数较多的循环起到优化作用。比如搬1000个箱子,让10个人来搬来搬肯定比1个搬快,而且大家只需要在搬完后交换一次信息,确认一下所有箱子都搬完了。想把更复杂的代码并行执行并达到优化效果,就要学更多的知识了。考虑到我所剩的时间不多,我打算只用OpenMP来并行优化循环。

我写了个算加法的循环,并用OpenMP并行优化。经过实验后,这个简单循环的运算确实变快了,优化成功了。看来,并行加速并不是很难啊。现在大作业代码的性能瓶颈还是那个指数运算。指数运算是作用在一个数组上的,目前的实现方式是循环对数组的每一个元素做指数运算。如果能把这个循环并行化,但代码的运算速度肯定能快上很多。于是,我把OpenMP的并行代码运用到了指数运算循环上。

可是,并行化后,代码不仅没有变快,反而变慢了。甚至随着并行线程数(可以理解为同时有多少个人在做同一个工作)增加,代码会运行得越来越慢。

做为一个自信的程序员,碰到问题时,我的第一反应肯定不是觉得我自己写错了,而是这个OpenMP调用得用问题。可能我使用这个工具的方法不太对。既然这样,也没时间去学新的东西了,没有轮子就自己造轮子,我只好自己来写一个多线程的并行程序了。于是,我删掉了OpenMP的代码,手写了创造线程、用线程做指数运算、同步线程的代码。我自己写的代码,肯定没问题了吧?

结果,用我自己的并行代码运行程序后,程序的运行速度也变慢了。做为一个正常的程序员,在代码全是自己写的情况下还发现了运行上的问题,第一反应就是出bug了。于是,我把那段并行的代码拎了出来,单独测试了好久。可是,无论怎么调试,都没有发现问题。第二天就在无聊而令人烦恼的调试中度过了。

晚上,躺在床上,苦恼地思索着代码里的问题。突然,我想到:是不是我的代码一直没有问题,而是并行这个方案有问题?做循环的次数只有10多次,如果用多线程的话,线程之间沟通的时间,就远远多于并行运算本身减少的时间。正所谓“三个和尚没水喝啊”。没办法,第二天就这么浪费了。我及时止损,准备使用新的方法优化程序。

经过昨晚的计划后,第三天,我早早地打开电脑查询一个新的技术。我顺着并行编程这一条线索,想到了另一个并行技术——SIMD(单指令多数据流)。这个技术就好比之前是一个人搬一箱货物,现在这个人可以拿手推车,一次搬几箱货物。在SIMD中,唯一增加的成本,就是货物得提前按组打包,这样才能够一组一组地搬运。这一项成本远远小于之前多线程之间沟通的成本。我去网上查了什么AVX指令集,学会了如何一次对4个数据进行计算。这样,不仅是那个指数运算,还有一些相邻的乘除法运算也可以顺便用并行

这次程序运算时间在8、9秒左右浮动。严格来说,程序并没有优化到三倍。但是,要是精心挑选一组比较看得过去的测试时间的话,应该能在报告里声称我把程序优化了3倍。这下,80分才算勉勉强强拿到。

代码真的就不能再优化了吗?既然老师敢说把速度优化4倍就能拿到满分,说明这份代码肯定还是有优化空间的。我把这份代码从头到尾读了一遍又一遍,在我的知识范围内,能用的优化小伎俩都用上了。但是,程序的运算时间几乎没有减少。我感觉自己已经弹尽粮绝了。

经过多次优化后,指数运算的用时占比已经不算很多了。一些对一个常量数组的遍历、取值、运算的用时占比逐渐高了起来。就好像一个邮递员要挨家挨户上门取件,再把货物送到另一个地方一样。这一部分的时间完全耗费在了跑到每个人家门口,敲门等人开门上,跑腿的时间反倒是可以忽略不计。这部分循环操作数组的代码是不能用之前的并行来优化的。这部分的代码没有什么过多的操作,自然也几乎没有改动的空间。我通读代码,看到这一团改不了的代码时,总会无可奈何地快速跳过。

我对代码优化已经绝望了。走神时,我想起了计算机体系结构课里的知识:现在CPU采用了流水线的设计,跳转指令(比如循环)会导致流水线的断流。为了让代码更快运行,某些时候能不用分支、循环就不要用。

我突然产生了一个奇怪的念头:循环吗……如果我把从常量数组取值的循环全部拆掉如何?这个可以理解成快递员上门取一层楼的货品时,可以用循环表示:取这个房间的货,往前走;去这个房间的货,往前走;……;如果这一层没有住户了,就上一层楼。但是,这是一个常量数组,即我可以提前知道这栋楼有几层,每层有哪几个房间。这样,快递员的指令就变成了:去101取件;去102取件……去606取件这样确切的命令。快递员不用动脑筋去观察什么时候把这层楼走完,可以上一层楼了。抛弃掉回产生跳转指令的循环后,按理说代码能变快很多。

但是,我们初学编程认识循环时,就知道循环是用来简化代码的。现在,我却要反常识地把循环拆掉,把要执行的代码展开来,一行一行写出来。这太反常识了。当然,循环拆掉代码展开后,代码会变得特别长,其中会包含很多重复的代码。与其我手动写,不如写个脚本自动把这些代码生成出来。于是,我写了一段生成代码的Python代码,把原来的循环拆掉了。

我持着怀疑的态度,一边苦笑地看着那些丑陋的代码,一边等待着程序运行结束。没想到,程序的运算时间竟然大幅度变少了!这次运行时间之间减少到了6秒多,程序的速度基本上是最开始的4倍了。我还没来得及烦恼怎么跨过优化3.5倍这道分数砍,程序一下就优化到满分了。

这太有趣了!违反常理地拆掉循环竟然能让代码加速。我关掉了再也不想多看一眼的代码,开开心心地把优化四倍的结果写进实验报告里。然后,我开始着手写这篇文章,记录一下这段过程紧张,结局却是Happy Ending的代码优化之旅。

噢,对了,源代码我是有的,但我一定不会开源。我能想象到,老师肯定会把同一份大作业用好几年。为了让这门课公平一点,我是不会上传代码的。当然了,稍微有一点编程水平的人,看完这篇文章后,都知道了该怎么优化程序了。这篇文章也算是给和我一样初学代码优化的人一些学习上的启发吧。

这是2020年6月份的文章,我当时写到一半搁笔没写了。趁现在有时间,赶快填一个坑。

现在重温这段经历,当时提笔时的激动已经没了,只剩下了怀念。两年前,我肯定想不到这四天不到的代码优化经历,竟然是我本科期间学代码优化技术学得最多、学习效率最高的一段时间,也想不到几年后的现在的几天后我即将开始新的工作,会把之前学到的这些代码优化技术全部用出来。

对了,最近我在写很多文章。今天不认真的文字写了3000~4000,之前比较认真的写得话一天3000字都不到。我本来以为这个速度很慢,上网一看,这才是正常速度。把写东西单纯当一个爱好也挺不错嘛。虽然既没有编程有趣,也没有编程挣钱就是了。

最近,我欣赏了《白色相簿1》的游戏和动画,心中五味杂陈,一直有话想说却不知道该怎么表达。今天,我从高烧中熬过了一晚,身体和精神上都得到了净化。趁此机会,我打算写一写我最近的一些感想。文章的结构和内容也不去仔细琢磨了。想到哪写到哪。

结果我没能够第一次性写完。第二次写的时候想了下,希望这篇文章能总结一些我的个人观点,并一如既往地传递我的积极的人生态度。

心理学之习惯论

社会学中有“原生家庭”这一概念。原生家庭,指孩子从小和父母一起构成的家庭。书本里说,一个人后天的行为都是原生家庭的再现。后天和其他人的相处模式,其实来自与和原生家庭里某位家庭成员的相处模式。心理治疗师在了解咨询人时,也首先会询问咨询人童年的信息。稍有调查就能发现,科学家们十分认同“童年对一个人后天的性格有很大的影响”这一观点。

仅基于这一观点,我根据我自己的观察与分析,想提出一个更一般的观点:人的性格,完全取决于人的习惯。什么是性格?拿“开朗”与“内敛”这一对相斥的性格来举例。当你和一个人讲话时,如果说这个人很开朗,那么这个人或许会笑着主动接过你的话题;如果这个人比较内敛,那么这个人或许回答个两句就不做声了。仔细一看,性格不就是某种意义上的习惯嘛:人在某种情况下最自然的反应,就是习惯。说一个人有早起的习惯、跑步的习惯,其实就是说一个人最自然的起床事件是早上6点,或者是一个人到了下午会自然地出门跑步。推广地来看,性格就是习惯。

如果只是把常识中的定义推广,那并没有什么用。我提出的“性格取决于习惯”的观点,有什么意义呢?其实我想强调两点:和你后天会养成跑步的习惯一样,性格不仅仅形成于人的童年;性格和习惯一样,是人长期以来刻在大脑里的信息,虽然难以改变,但是坚持下来还是有改变的可能的。

我认为,人的大脑在遇见新的事件时,会产生一个面对这个事件的解决方法。如果这个方法总是有效,人在面对特定事件时就会有一套固定的反应。这就是人的习惯。人在面对自己的情感时做出的习惯反应,就是性格。由于人在一出生时就要面临饥饿、恐惧等会伴随人一生的情感,所以人的性格大都在童年时被决定下来。人在后天也可能获得某一性格,想象一个从小衣食无忧的富家子弟,突然要在荒郊野外一个人生活,那么这个人会在后天才培养起面对负面情感的性格。总之,新性格诞生于新事件中形成的固有反应。

当人已经形成了某一性格时,这通常意味着人已经从许多生活经历中巩固了这一性格:比如一个人健谈,可能是他从小只要好好和父母交流,就能满足自己的需要;可能是从小喜欢和朋友聊天,感到很快乐;可能是喜欢让他人听自己侃侃而谈,享受被尊重的感觉……在长期生活经验的影响下,这个人的大脑已经形成了“只要和别人多说话,就能给自己带来快乐”的简单反射。这些一条条生活经验,使一个人的性格根深蒂固,成为了人的一部分。不过,反过来说,人的性格还是有改变的可能,只要逐条否定自己过去的做法即可。一个人习惯说谎,可能是小时候每次说实话都会被父母骂,而撒谎总能成功逃避。要彻底改变爱说谎的性格,需要面对自己童年的伤痕,思考自己以前每次说谎的后果,让自己彻底明白说谎不总是能让自己和他人变好。

这里从程序的角度总结一下我的“习惯论”:人在面对事物时,会把自己的计划写在一张表格上。表格的第一列是碰到的事件,第二列是碰到事件的解决方法。事件是”碰到饥饿“、”碰到恐惧“这些低层次的情感或者是”是不是要起床“这些很容易表达出来的事件。人在碰到新事件时,会另起一行,记录下事件的名称和自己的解决方法。下次再碰到同样的事件时,人会尝试同样的解决方法,并试图加以修正。当这个解决方法已经用过多次后,人在碰到事件时就不会加以思考,而是顺其自然地采用固定的解决方法。这种处理机制的动机也很好理解:人的思考能力是有限的,如果什么东西都要过一遍脑子,人早就累死了。因此,人通过”性格“这种采取过去相同行为的优化方法来减轻自己的思考量。

孤独感从何而来

上述内容只能算是一个不严谨的观点。我这辈子应该没时间去研究心理学,不会将其系统化。我之所以写那么多,是想提供一种心理分析工具,来分析人的各类心理活动。

孤独,展开来说是人感觉“一个人很难受,如果有人在自己身边就好了”。做为一种常见的情感,大家都可以轻松地说出孤独的定义。但是,仔细一看,为什么人们都会觉得“如果有人在自己身边就好了”呢?仔细去挖掘这一想法的动机,会发现孤独并没有看起来那么简单。

既然孤独是一个所有人都会有的情感,那么分析它就要抓住所有人的共性:婴儿时期。刚出生的时候,所有人都是十分相似的。大家对这个世界一无所知,却天生被赋予了“要在世界上努力活下去”的本能。饿了,会哭;热了,会哭;要睡着了,会哭……在身体上有不适时,婴儿会害怕。这时,通常母亲会安抚婴儿的情绪。婴儿第一次认识到了其他生命的存在,知道了其他生命能够减轻自己的负面情绪。在成长的过程中,人们认识了其他亲人,认识了伙伴,发现确实和他人相处能够令自己更加安心,这一条解决方法被记录在了人的大脑里。反过来讲,遇到麻烦时,也会下意识地寻求他人的帮助。寻求不到,就变成了孤独。

按理说人长大了,学会自己找东西吃,自己生活了,不会再有幼年时的那些担忧了。为什么还会感到孤独呢?这是因为,孤独不仅是由饥饿等简单的情绪构成的。只要身体上,或者尤其是心理上有了不适,人感觉自己已经无法处理一切后,就会像刚出生的婴儿一样感到害怕。过去的习惯让人下意识去寻求帮助,但却发现周围的人已经帮助不到自己了——随着年龄的增加,人的烦恼也愈发复杂,终有一天,人的烦恼只有自己能理解。所以,人感到了孤独。

贫瘠的表达能力

一瞬间的不适,只能归于害怕。长时间的害怕,才足以称之为孤独。正因为人与人之间的交流效率实在是太低了,以至于人在大部分情况下无法互相传递情感,才导致了孤独的常驻。

相比动物,人发明了语言,发明了文字,似乎就拥有了无穷的表达能力。但实际上,人类之间传递信息的效率比想象中要低出许多。

相比大家都有这样的经历吧:向他人问路后,他人自顾自的讲了一通。碍于面子,我们只好感谢对方的帮助,再顺着他人指着的直线方向寻求下一个人的帮助。在参观点一份套餐,我们只需要报出套餐的名字,甚至只要简单说一句“我要那个”就够了。交谈的时候,我们很多时候并没有听清别人在讲什么,有的时候仅仅通过对方的申请才大概脑补出对方的回答是积极的还是消极的。就连有充足时间组织语言的文字聊天,不配上表情包什么的,总是会令对话十分尴尬。

哪怕有了语言和文字,人类之间的沟通效率还是太低了。不然,为什么一些简单的概念要花45分钟来讲清楚呢?为什么同样是上课,有人学得好,有人学得差呢?为什么有的时候几行代码的事情要花好几页的长篇大论来解释呢?

要是说构筑于理性之上的深邃的理论,只要花上时间,不管再久,都是能够讲清楚的。那么,更加复杂的,用感性编织出来的无形的情感,有时恐怕用再多的言语也无法说清楚吧。

我曾经为了追寻高质量的交流,严密地建立了一个人的交流模型。人与人之间交流的效率,或者通俗说是深度,至少由交流欲望、对交流背景的了解程度、思辨能力相乘而得(相乘意味着只要一个因素偏低,最终的结果就也会偏低)。比如说想进行深刻的学术交流,就要在双方午后都乐意探讨学术时,与在同一领域做研究的聪明而乐于表达的人进行交流。

把这个模型放到情感上,就会得出非常悲伤的结论了:人和任何人交流情感的效率,几乎都无法超过自己和自己交流情感的效率。人为了处理自己的不安,有极强地感受自己情感的意愿;自己一般是最了解自己的。只有思辨能力,算得上是一个可变的因素。有些人处理自己心理的能力不行,有的时候对情感的沟通效率没有深谙人心的心理咨询师强。但在绝大多数情况下,人只能感受到自己强烈的情感,而他人是无法体会到相同的感觉的。

长时间的害怕孕育了孤独,人自然地寻求着效率最高的,自我心理疏导的方式来排解这些负面感情。或许熟练之后,害怕的感觉不在了,那个被称为“孤独”的感觉却伴随着只能自我对话的习惯,永远凝固并附着在我们的心上。

在一起

在我看来,人注定是孤独的,这是一个从理论分析上来看无解的问题。

这是一个所有人都自然而然会碰到的问题。幸运的是,几乎所有人都在不自觉地努力寻找解决孤独发方法。

虽然从前面的分析可以看出,孤独不是那么简单的一个概念。但在大众普遍的认知里,孤独的原因是“一个人”。要是能和另一个人长期待在一起,就不会有孤独感了。因此,在大众的认知里,两个人交往,意味着两个人会“在一起”了。

但是,在一起真的能解决问题吗?

如果是为了身体上的接触,那通过基因获得的一瞬间的快感是五八支语长久以来的孤独的吧。如果是恋爱的话,也只是在激素和新鲜感的驱动下,从一个幻想中的完美对象里汲取自己欠缺的情感。哪怕是数十年亲人般的陪伴,也有可能只是过多的共同经历把双方重塑成了一个新的集体,分离已经成了如伤害自己一般不会去想的选项了。

再近的距离、再久的同行,也无法解决情感无法交流这一本质问题。

但或许,消除孤独并不需要无损地体会或传递情感。相处得久了,两个人之间可能会产生的能力:不需要完全理解对方,只要一个信号,就能顺利地索取与给予所需的情感。在这种情况下,两人之间构造了一种新的沟通方式,一个简单的行动,几句简单的叮咛,反倒能化作携带了无数信息的暖流,流入人的心里。说到底,人只能体会到自己的想法,自己因为害怕而寻求帮助,所以也能够用自己的方式去理解别人的行动。

不过恕我直言,在我的观察统计下,凭大部分人的思考水平,世界上能做到这种程度的家庭少之又少。只有少数的夫妻能够较好地消除孤独,并把美好而恰当的关怀传递给孩子。

向世界宣泄自我

如果没有人能好好倾听自己的复杂的想法,为什么不干脆放弃向某个人说明,并用自己的方式把自己的想法完完全全地展示出来呢?

之前我看到网上有人讨论,为什么伟大的艺术家或思想家都有抑郁的倾向。有人说,不是他们创作后才抑郁,而是因为抑郁,参创作出了伟大的作品。我认为这是正确。越是进行深刻的思考,越是能得出常人难以理解的结论,越是缺少和常人的共同语言,孤独感越发沉重。于是,他们选择逃避,逃避进了自己最擅长的事情里,继续思考着。在正反馈的作用下,不被常人理解的痛苦加深到了难以忍受的程度,只好放弃与常人的交流,把自己长久的以来的感受宣泄给整个世界。

但要说那些创作者是抑郁吗?我看未必。固然有很多创作者最后都选择了自杀,但哪怕是这些人,他们生前都是热爱着生活的吧。正是因为抱有着热爱,所以不断创作着,不断发泄着情感,不断与黑暗的内心抗争着。他们明明知道,自己的作品被会被其他人欣赏,赞美的话语永远无法传进他们的耳内。即使如此,他们还是把作品留传下来,把高尚的思想传递给他人,给予每一个和自己相同困境的人以温暖。这样的人,绝对称不上是对这个世界失望吧?

中学时,叫我写一篇800字在作品会让我头疼死。但后来,我发现有东西想写时,文字就会从我的指尖流到屏幕上。先是理论,再是情感。只要有想表达的东西,这些抽象的想法总能转换成具体的文字。既然如此,那艺术家就更加幸福了。他们有画笔,有音符。无限的信息,被压缩到了简单的实体中。再也不用考虑有没有人愿意倾听,再也不用考虑有没有人能够理解,只需要把自己的情感,原原本本地创作出来就好了。

结语

在人的出厂设置被确定后,人的孤独就是不可避免的事情了。但也不必过度悲观,选好表达方式,想好表达对象,每个人都可以战胜孤独。

今天不吹牛、不搞笑、不乱用第一人称叙述,好好写一篇正经点的文章。

《新加坡2022》游戏攻略(一):凌晨四点的香港

你认为出国是一件有趣的事吗?我想,大部分人的答案都是肯定的。出国嘛,在新鲜的地方,碰到新鲜的事物,想必是充满乐趣的。可是,一出国我才发现,出国,尤其是出国居住一段时间而不是出国旅游,是一个充满挑战的过程。你需要克服沟通上的障碍,了解当地的生活方式,迅速把国内的生活经验迁移到新的国家里。又正逢全球疫情,严格的防疫政策更是令适应新生活的难度大增。对我来说,这几天的出国体验与其说是游玩,不如说是攻克一款角色扮演游戏。接下来,我将按时间顺序分享一下持S-Pass(新加坡的一种工作签证)在疫情尚未结束的2022年在新加坡安顿、生活的经历。希望这篇文章能给即将出国却没有出过国的人一些启发,同时也能让大部分人看得有趣。

冒冒失失地启程

如何用一段文字来描述上海地铁的拥挤呢?

一般人肯定会去描写上班高峰期上海地铁的“盛况”:地铁像浸满了水的海绵一样,再用它来吸水的话,能把水滴吸进去,同时也会把一些水漏出来。

但我不会这么写。我会写道:九点半,上班高峰期已过,通往徐家汇的地铁上依然挤得水泄不通。

要问我为什么会知道这件事?没办法,出国前最后一天,我还是起了个早(相对而言),被迫体验了一次上班期的上海地铁。为了完成任务:获取核酸检测报告,我不得不在早上前往离宾馆较远的一个医院。

任务名:获取核酸检测报告

任务背景:我是在头一天晚上先在香港中转,第二天下午4点抵达新加坡。入境香港和新加坡时,需提交入境时前48小时内的核酸检测报告。

任务目标:

  • 获得时间合适的核酸检测报告
  • 核酸检测报告必须是英文

如上所述,我在出发第二天下午入境新加坡,最早要在出发前一天下午4点做核酸检测。考虑到医院的服务时间、入境时间与飞机抵达时间的时间差,我打算出发当日上午做核酸检测。由于核酸报告必须是英文,我保险起见挑了家只出双语检测报告的医院(我不想打广告,就不说医院名了,可以轻松地在网上搜到)。于是乎,我只好一大早挤地铁赶往那家离我的宾馆较远的医院了。

仔细一想,这么早起来挤地铁,完全是转机害的。要是能够直飞过去,就没这么多麻烦了。可是,从香港转机到新加坡,只要2000多元,其他的转机方案都7000起步,从上海直飞更是10000起步。不过也托了是住在上海的福,能有从香港转机的选择,国内其他地方似乎连这个实惠的选择都没有。为了省点钱,踩着时间点去做核酸检测这点苦头还是得吃的。

我9:50到的医院,酒店12点退房,医院到酒店的交通时间是半小时,时间并没有那么赶。但是,登记检测信息时,工作人员热心地告诉我,我没有在个人信息中上传护照信息,打印的报告里不会有我的护照号码,建议我登记了护照信息再做检测。同时,他还告诉我只有在上午11点前完成检测,报告才能在下午4点时得到,否则报告得第二天才出来。我的护照还在宾馆里。这样一看,时间非常紧迫。我算了下,现在回宾馆,最早也是10:50回到医院,太赶了。同时我还回忆起来,新加坡仅要求核酸检测报告上有证明身份的信息,除了护照号外,出生日期也算是一种身份证明。相比于报告上没有护照号,在规定的时间里得不到检测报告更成问题。于是,在0.03秒内,我完成了回忆与利弊分析,没有选择回去,而是直接做了核酸检测。

出发的一大早,我就碰到了一项需要决策的挑战。虽然略显冒失,但我还算是成功了。没想到,这次的挑战仅仅是个开始……

上海机场

做完核酸检测,我在时间充裕的情况下整理好了行李,在宾馆退了房。我身背沉甸甸的登山包,一只手推装了Switch、PS5、书、衣服的旅行箱,另一只手把杂物袋按在旅行箱上辅助推行,就这样踏上了旅程。

刚才的核酸检测任务还没有结束。报告是4点钟给出,但我4点钟的时候必须要赶到机场了。报告是电子版的,我不可能去医院要一份纸质报告,只能在机场打印。还好上海浦东机场是有打印服务的。我按照网上的信息,在T2航站楼2楼星巴克对面找到了打印处,花6元打印了一张纸的报告。至此,获取核酸检测报告任务正式完成。

我还预约了携程的取外币服务,在A号值机口附近成功以1:4.9的高价换到了一点新加坡元(正常的汇率是1:4.6),就当是花一点小钱体会一下为什么贪官的资产不好转移到国外吧。

正当我悠哉游哉地准备去托运行李时,猛然看见“充电宝禁止托运”这一信息。我的充电宝塞在大旅行箱里。为了避免未来的大麻烦,我强忍着麻烦,打开了旅行箱,从衣服堆中挖出了充电宝。果然,我碰到了意料之内的麻烦——行李被打乱后,旅行箱关不上了,这可太麻烦了。我只好把最厚的棉袄从旅行箱里拿出来,这才解除了麻烦。没想到,这件棉袄在后面还帮到了我,正所谓麻烦反被麻烦误啊。

国际航班的值机处的服务非常到位。工作人员细心地帮我检查文件,还叮嘱我要提前下好一个填新加坡入境信息的APP。国际航班的安检处人也很少,总算不用排老长的队了。我刚想多夸几句国际航班的好处,就被安检给刁难到了。安检时,不仅要脱外套,带金属的皮带还得脱——唉,脱就脱,反正是男生嘛。什么?螺丝刀、剪刀都不能带?唉,扔就扔吧,亏我当时特意从房间里翻出来的。怎么书包检查了好几次还要检查?书包里电线太多了?为什么叫我们拿出电子设备的时候不叫我们把电线也拿出来啊。真没想到做飞机安检的时间能超过安检排队的时间。

我向来是讨厌提前到火车站或机场的。但今天我特意在起飞前提早了3个半小时到机场,时间还将将够用。感觉机场的主线任务也不算好做啊。

第一趟飞机

上了飞机,整趟旅途最舒服的时间到来了。

我第一次坐上了两列过道,一排8个座位的大飞机,第一次在飞机上看到可以自己选择节目的机载电视。飞机上的电影都挺新,我本来兴致勃勃地播放起了柯南最新剧场版,却偶然看到隔壁大哥正用耳机连着电视,又一看电视只支持USB的耳机口,我没有能接USB的耳机,因为不能享受到最完美的体验,一气之下关掉了电视。

还好我是电脑不离身的人。我反手就掏出电脑玩起了不吃资源的小型游戏。飞机上微凉,我正好披上棉袄,不冷不热刚刚好。就这样,我在娱乐、就餐与瞌睡中,度过了舒适的机上时光。

没想到啊,飞机上我不仅享受的是最后的晚餐,还是最后的睡眠。

折磨人的过夜转机

在香港转机,钱是少花了,可麻烦事一点也不少。我必须在香港机场停留,直到去新加坡的航班启航。我得从晚上9点多停留到第二天约中午12点,在机场候机室过夜是免不了的了。

任务名:香港机场过夜

任务背景:在去新加坡的航班抵达之前,我必须一直待在香港机场候机室。

任务目标:

  • 熬过这段时间

在订票的时候,我就已经做好了在机场过夜的准备了。我看了下别人的攻略,得知可以躺在三连坐的座位上睡觉过夜。但我也做好了最坏的打算,反正早上6点抵达上海的绿皮火车我都熬过来了,随便找个地方靠一靠,一晚上睡不好也没什么问题。

办完第二天新加坡航班的登机牌后,我找好一个有插座的座位,搭起了一个临时个人领地。这个有插座的座位是二连坐,躺不下来,果然事情没有那么顺利。幸好我准备了后手——我在出发前,买了三包装的抽纸,这些抽纸放在我手提的杂物袋里。我在到上海机场之前的那个午休时,恰好发现放在行李箱上的杂物袋里的抽纸可以做为我坐躺时的侧面靠枕。实验表明这个靠枕睡起来还挺舒服的。我本来准备把这个天然靠枕装置做为香港机场过夜的杀手锏,却猛然发现我的行李箱早就拿去托运了。看来我的准备还是有一些漏洞啊。

没关系啊,我也不亏,有无限电量的电脑,就可以通宵玩游戏了。在机场转机,就省了几千块钱。等于说我通宵玩游戏一晚赚了几千块钱。这么一想我不仅不亏,反而赚大了。

小玩了一会儿,我想起还有个新任务要做:

任务名:新加坡租房

任务背景:我要在新加坡有一个临时的居住地,离学校尽可能近,不能太贵。

任务目标:

  • 获取足够房源
  • 成功解决居住问题

我之前就计划在过夜的时候来搜集新加坡房源信息。出发之前,我总会觉得时间还早,而且不能看房,搜集信息没什么意义。在有充足时间,且事情愈加紧急的情况下,搜集房源是最高效的。

之前我租房都受到了帮助,因此没花太大功夫。这次,对租房方式的不熟悉加上对国外的不熟悉,令搜索房源成了件对我而言极其困难的事。还好这晚时间有多,加上我过人的智力与游戏装备购买比较经验,我迅速掌握了新加坡租房网站(propertyguru)的用法,了解了学校附近租房的普遍价位。当然,实话实说,并不是我搜集信息的能力很强,而是学校附近的房子都太贵了,在我预算之内的房子根本就没几间。若是加上整租这一需求,满足条件的房子中仅剩下了一所公寓。这下也好了,没得选择,等于不用选择,等于选择完毕。很早之前我就了解过这所公寓,条件和价格都没什么大问题,到时候直接去住就行了。

想到这里,我心中的一大重担总算落地,心情瞬间愉悦起来。接近凌晨两点,机场笼罩着昏昏沉沉的气氛,只有我一个人大幅度地摆着手,在机场里兴奋地踱步,想象着之后的美好生活。

可惜,大脑和胃部同时向我发出了警告,我只好老老实实地回到座位上,以低功耗模式思考起来。我啃起了早就准备好的巧克力,令大脑可以持久地工作。

夜还很长,该干些什么呢?机场灯火通明,施工人员在最不会打扰到旅客、最恰当的时间里轰轰隆隆地进行着机场设施的修建,过夜的旅客们则瘫倒着勉强地休息着。这令人讽刺的对比太过于强烈了,我忽然灵感涌上心头,想动键盘写下一些东西。可惜灵感和情感到位了,大脑的运算能力不够了。我望着机场的天花板——香港机场的天花板,由三角形的金属板拼接而成。拱形的屋顶连接着候机室的两侧,由长廊的尽头延申而来,犹如巨龙的鳞片一般。机场内灯火通明,可黑夜中的天空却从屋顶金属板间的缝隙间透了进来,提醒着人们这是凌晨两点的候机室……我大概是想这样动笔,从景物写到在机场睡觉的人,讲一讲机场的不人性化,感慨下大伙儿转机的不易。但我的大脑当时太困了,空有思路,想了好久也没想好该怎样把天花板的模样描写清楚。

我意识到自己困了,开始准备睡觉,可机场恶劣的环境又令人难以入睡。我只好选了一个令我最容易入睡的方法:我找了个提供桌椅的办公区,以最接近上课时的坐姿趴在桌上,一边想象着老师讲课的场景,一边催自己入睡。果然,还是上课时的睡眠最香,我很快就睡着了。顺带一提,我是披着棉袄睡的,棉袄在制冷系统完美运行的香港机场又帮了我一次。

天亮了,为了时刻关注登机的信息,我回到了离其他人很近的原来的座位上。我靠在座椅上,第一次觉得薄而硬的座位是那么适合睡觉,惊醒后又立刻贪婪地入睡是多么令人满足。加上巧克力的能量续航,我在没有饿死也没有困死的情况下熬到了抵达新加坡的一刻。——前往新加坡的航班并没有提供饮食,对我而言坐飞机只是让睡觉的椅子变得软了一点而已。

Hello Singapore

到了新加坡,我看到软绵绵的床和香喷喷的美食已经在向我招手了,肚子反倒不饿了,精神也抖擞了起来。

在入境处,我提供了以下材料:

  • IPA(入境基本证书)
  • 核酸检测报告
  • 疫苗报告(是用微信小程序防疫健康码国际版生成的)
  • 护照

此外,在之前的两个机场除了提供这些材料外,也有若干信息要填。上海机场出境前要填什么海关信息,填去往香港的健康签名;在香港机场要填去新加坡的健康签名,还要填什么ICA表格。反正主要是打好疫苗,准备好核酸检测报告就行。剩下的资料工作人员都会帮忙指导。我算是比较幸运,在新加坡入境处花的时间少于上海和香港办理值机的时间。

按照之前的计划,我本来是要在机场买好手机卡和交通卡的。但我糊里糊涂跟着同行的旅伴走到了打车处,他告诉我没做完核酸检测前是坐不了公共交通的。我也没心思去思考他这段话是什么意思,只是想快点打上车,把行李放到宾馆里。

的士来了,司机是一位黄种老人,头发花白,却精神地帮我搬着行李,用老绅士来形容再贴切不过了。上了车,我说道:”I want to go to this place (我想去这个地方).” Meanwhile I show the destination on the cell phone to the driver. 啊,不对,是我同时还给司机看了手机上标出来的目的地。

我这才发现,随着离国内越来越远,生活的方式变得越来越陌生。而我,得去主动适应这全新的环境……

2022年3月博客广告

最近我可以在毫无压力的情况下放假了。我准备写一堆文章出来。敬请期待。不过现在肯定没有人会天天盯着我的博客,大概看的时候我已经把所有文章都写完了。

我和大家坐在一起吃饭。

把“大家”称作“同学们”的话,有点不太恰当,因为其中有些人严格意义上没有和我在同一间教室里一起学习过。倘若把年纪相近,在同一所学校同一段时间的校友都称作同学的话,把大家都叫做同学才算是勉强正确。

那为什么回到家乡后,第一件事是和大家出来吃饭呢?

因为在我看来,大家都是“朋友”吗?


六七年前,同样一批人也是这样坐在一起吃饭。

但是,和大家在一起交谈时,我并不是很舒心。

我和有些人并不是很熟,不能像关系特别好的同学一样,自由自在地交流。

在那时,因为某些原因,我不得不强行找到一些能够交谈的人,以用一层薄纸封住我空虚的内心。

又因为某些原因,这层薄纸被捅破了。我发现,我的内心没有变得充实,反而日渐腐烂。

在修补内心的同时,我开始憎恶起这层阻碍我看到真相的纸来。

什么样的人才可以称得上朋友呢?

那恐怕只有和我思考水平相当,能够和我发自内心探讨深刻的问题的人吧。不需要花费多余的心思,只要所心所欲地说出自己真实的想法就好了。

以这个标准看去,我没有几个朋友。

也好,剩下的人对我来说,怎样都无所谓了。就是他们害得我没有及时发现自己内心的病症。

果然,如我所愿,我的内心一天天强大起来。只寻找这种意义上的朋友,成了我的信条。


时至今日,我的想法似乎还是没有怎么改变。

随着时间的过去,我已经不用再费心思考朋友的定义,能够自然地和人接触了。

但如果有人问我什么是朋友,我依然会思索一会儿,再给出之前那一模一样的回答。

如果是在今天之前,还没有和再次大家坐在一起吃饭之前,我肯定是会这样回答。

现在,在这个饭桌上,之前就没什么共同语言的同学,如今也没有太多的话可以讲。

现在坐在我旁边,以前曾经玩在一块的同学,也因多年经历上的割裂,而不知道从哪个话题开始聊起。

大家都只好聊着吃饭之前,下午一起体验的游戏项目。

最近的共同经历,只有刚才的那几个小时了。再不然,就是中学时的那些事情了。

几年前,和同一批人相处时,那坐如针毡的感觉再次传了过来。

可是,我已经不是以前的我了。面对这令人恐惧,仿佛要把我拉回过去的感觉,我选择了抵抗:我要尽快离开这里,远离大家。我不想变回过去那个空虚的自己。

嗯,如果是真的和朋友一起的话,会有这么令人焦虑吗?


但是,吃完饭后,大家把我拉到了下一个活动地点。

顺应着气氛,我和大家正常交流着。

就和今天下午一样,我们只是说着普通的话。

就像以前一样,没有深刻的思想,没有刻意的组织言语,我只是和大家说着话而已。

那不就和以前一样了吗?我仿佛是回到了因为心灵的空虚,而和他人交流的状态。

我已经不是以前的我了。我应该讨厌这样的情景才对。

可是,不知道从什么时候开始,我不太想离开这里了。现在的一切,不但不令我讨厌,反而让我觉得怀念。

正因为我已经改变了太多,能够再次体会到以前相同的感觉,就像是回到了以前一样。就好像这只不过是期末考试过后的一次聚会而已。

仔细想一想,过去的我的体验真的有那么糟糕吗?为什么我会沉醉于不再能回到的过去呢?

和不是朋友的人在一起的时光,会令人这么难以割舍吗?


所有的活动结束后,我和一个同学恰巧在一起坐地铁回家。

以前,我们好像是经常会聊好玩的游戏。现在的我们,在玩的游戏上,似乎没有什么交集了。

我逐渐找回了现在的我的状态,主动地找了一些有意义的话题。我向他询问了近来的状态,并试图稍微聊一聊学科、职业这种稍微深刻一点的问题。

但是,和我预料得一样,谈话很快就以沉默告终。

有了四五年经历上的差别,我们早已像两束发散的光一样,射向了永远不会再次相交的远方。

或许从一开始,我们就没有相交过。我们性格之间的差异,本来就不足以支撑长时间的有效对话。

不过是恰巧,能够在同一时间、同一个教室里上课而已。

地铁即将来到换乘站,他要下车了。我像是要抓住什么似的,提前祝他新年快乐。

他笑着说,过几天在祝福也不算迟。

啊,我都忘了,过几天大家还会出来聚一次会。说新年快乐,还是有机会的啊。

但是,说再见的机会应该没有多少次了吧。

他……之前是我的朋友吧?

那么现在也一定还是我的朋友。


下了地铁,我走在陌生的街道上。

说是陌生,只是因为地铁的出口建在了一个比较偏僻的巷子里。毕竟之前是没有地铁的。

什么嘛,只要走出小巷,还是我熟悉的街道啊。

说起来,之所以我对这里很熟悉,是因为我曾在附近住过一段时间。

我还在家乡的时候,换了好几次住处。今天我们跑了好几个地方,恰巧经过了每一片我熟悉的区域。

我一步一步走着。我努力地看着四周陌生的店铺与熟悉的道路,希望能把这一切的场景都在脑中刻下一丝印记。

为什么连一草一木也令我感到不舍呢?难道这些没有生命的场景,也是我的朋友吗?

什么是朋友呢?我又一次问了自己同样的问题。

能够和我探讨深刻问题的熟人——今天之前的回答是这样。

能够轻松地交谈的熟人——好像不是所有人都能找到合适的共同话题吧。

关系不错的人——这不是一般意义上的,对“朋友”两字的详细而无用的描述吗?

共同拥有一段愉快的时光的人——

这是我最终得出来的结论。


离别之际,人为什么会对其他事物感到不舍呢?

经我的观察发现,人是自私的。

人自出生之际,就只能体会到自己的感受。人只会为了让自己获得更多的美好的感受,而贪婪地活着。

反过来说,人并不会主动在意其他事物,除非这些事物能够给自己带来好处。

或者,这些事物被当成了某个人的所有物。在意这个事物,就像在意自己那样符合道理。

路边的景色。

远去的故友。

这些事物显然不是能直接给我带来好处的东西。

可是,这些东西怎么也不像是我的所有物啊。

唯一能解释的就是,人不仅会把有形的事物当成自己的所有物,还会把经历当成自己的一部分。

对于家乡的风景来说,这里记载了我成长的一幕幕。它是我的经历中的一部分,是我自己的一部分。

对于人来说也是类似。和他人一起的,令人开心的经历,是我的一部分。

可是,朋友不是那么简单的一个词。朋友,可是要得到两方的认可才行啊。

那么,就这样解释好了。我们共同拥有一段愉快的时光。两段蜿蜒而不断延申的人生曲线上,那不起眼的几个交点,是我们人生的一部分,是我们互相视作朋友的证明。

离别,意味着再也见不到某事物。

意味着那些时光、那些风景不再会有了。

意味着人永远损失了一些东西。

面对不得不经历的损失,自私的人类会感到不舍啊!


最近在玩“素晴日”,里面有几个话题令我很感兴趣。

一个是说,我们每个人都有一个自己的世界。

这个想法不假。人只有感知到了世界,思考并回应着世界,世界对人来说才有意义。

每个人都触碰了世界的一部分,对世界的交互方式有自己的理解。

那些东西,正是我们每个人自己的世界。

在我的想象中,每个人的世界都是外观一个不断的变动着的球。

说它是球也不算准确。球是三维的,只能和我们能看到的一切一样,记录三维的场景。

可是,自己的世界不仅有我们认知某一处的场景,还有我们在不同时刻见到的场景。

自己的世界,还包含着属于自己的经历。经历是有时间这一维度的,所以哪怕说我们的世界是球,那也得是一个不断变动,反映着不同时间的场景的球。

每个人都有自己的世界,即每个人都有一个属于自己的球。

总能找到一个时刻,两个球有了重叠的部分。

所以不用太悲观啊!即使同意了世界是由自己定义的这个观点,也能在自己的世界里找到别人的痕迹。

还有一个话题是说,人是唯一认识了死亡的生物。

事实上,死亡是生物永远也达不到的状态。生物死亡,即永远失去了“生物”这一称号。

所以,其他的生物都安然地活在世界上。

只有人会畏惧死亡。

为什么不会体会到的东西,会令人害怕呢?

我想,这是因为人体会过失去吧。

咽下去的东西,就再也体会不到它的味道了。

即使能重复做同样一件快乐的事情,同样的事情带来的乐趣终将令人厌倦。

只要是人,是有着高级智慧的人,就会意识到失去的存在。

死亡,意味着失去所有令人快乐的事物。

所以,人哪怕永远不会见到死亡,也害怕自己的世界被剥夺。

生活的价值,就是死亡失去的价值。

那也就是说,正是有了注定的死亡,才会有努力活下去的价值。

死亡的本质是失去。

那么,正是离别,赐予了我们朋友的价值。

合眼告别美梦

七月,刚来上海参与为期半年的实习时,公寓旁地铁口前的野花还鲜艳地长着。哪怕是两场台风过后,树丛中仍能看见几抹彩色。可惜,最冷的新年一月来到了,再也不能看到树丛里的花朵了。

我所在的公司,离最近的大学只有数公里之隔。但校园内外的风景,却有天壤之别。校园里,只有低矮的教学楼,来去匆匆的行人,每天能看到的净是沉闷而重复的事物。而公司所在的写字楼有四十多层高。站在落地窗前,能看到黄浦江与天空交汇在一起,楼盘、马路、汽车,不过是背景板上的几处点缀。

刚来到办公室时,我还有点战战兢兢,不敢站在高处向下看。很快,我熟悉了周围的人,熟练了手头的工作,走遍了附近每一家美味的餐馆。现在,再站在窗前俯视大地时,我只会悠然地欣赏着外面的景色。

我的工作并不累人——当然,有些时候除外。十二月,我们组的项目即将上线。全组人都绷紧着弦,马不停蹄地赶着进度。月底,在全组人的掌声中,我们的项目如期面世。很快,项目受到了公司内外的一致认可。项目宣传稿发出去的第一时间,从不关心社交平台的我立刻转发了这篇宣传文。按下“发送”键后,手机界面跳转到了我的朋友圈,我忽然笑了出来:短短一分钟里,我的朋友圈被同一条宣传文刷屏了。而发出消息的,全是我们组里的人。大家虽然没有把激动的心情表达出来,却在宣传文发出后立刻不约而同地转发了这篇文章。

项目完成后,恰好要进行年终述职。身兼开发与管理二职的小组主管自豪地汇报着我们小组这几个月的工作内容。看着我参与过的一项项工作,我的思绪不禁回到了五个月前。

刚来公司的时候,我的编程环境怎么都配不好,连办理企业微信都花了整整一周。

来公司两周多后,我才初次为项目贡献了代码。虽然在现在看来那只是一份简单的修改,却让我感到自己总算是融入了整个小组。

后来,我实现了一个完整的功能,甚至还主导了一次大型的代码重构。

我习惯了与组内的前辈认真讨论工作的内容;我学会了其他部门共同协作;我结识了其他组的朋友,和他们一起聚餐。

总算啊,我们的项目上线了。大家的付出,都有了回报。

未来,这样的生活应该还会进行下去吧?

项目上线后,还会面对更多的挑战。我还会一如既往地为项目贡献自己的一份力。我越干越久,技能越来越丰富,说不定能独当一面,主导更多的工作。

凭借我的条件,找个门当户对的女朋友,安安稳稳地结个婚应该不是很难吧?到那个时候,我应该会搬出出租屋,租一个大一点的房子。

再攒个几年的钱,应该就能凑够房子的首付款了。我总算能住在自己的房子里了。是不是考虑再买辆车?或许还要考虑生孩子的事情了?

是啊,随着时间不断流逝,一切都会顺利地进行的。

到那时,我不再是骑自行车,而是开着汽车来到写字楼下。

我会穿着更成熟,更得体的衣服,与熟悉的同事们打着招呼。

敲一敲键盘,吃个午饭,睡个午觉,转眼就到了下午。

今天是每周组会的时间。在会议室里,我又会看到小组主管。

听着她汇报着我们小组的工作内容。

“……今年我们组把代码库开源了。明年,我们会探索更多方向……”

看吧,就像这样,她还会总结着我们组的工作内容。

“……我们会为我们公司做出更多贡献……”

嗯,为了我们公司。等等,“我们”的公司。那是谁的公司?明年一月,我就要离开这里了啊!

”……以上就是我的述职报告。“

小组主管完成了她今年的年终总结。我猛然回归神来——轮到我进行2021年的年终述职了。

”我虽然只在这里实习一小段时间,但我依然出色地完成了我的工作……“

和之前准备的一样,我熟练地开始了我的演讲。

”……在介绍我的具体工作之前,请允许我带大家一起回顾一下我的成长轨迹……“我把刚刚回忆出的内容,一字不差地描绘了出来。

当然,我没有讲出之后”梦境“里的那些事情。梦里的事情,是不是永远无法成真啊?

”……具体来讲,过去我做了这些事情……“

不对,过去的几个月对于我整个人生来说,算是美梦一般的存在了。刚毕业,没有家庭的压力,没有身体的负担,幸运地来到了这里。这是我之前想也不敢想的生活啊!

”……未来,可能我在这待不了多久了。但我真的很怀念在这里的时光,会继续当我们项目的社区贡献者……“

这真的不是一句客套话,这是我的真情实感啊。我虽然没和组里每个人都认真地聊过天,但我们曾经谈论过无数小细节,在同一片办公区里一起工作了几个月。没有组里的大家,哪有最后的成果,哪有这么开心的时光呢?我的想法,能传达给大家吗?

”……我在过去的工作里学到了很多。未来,我还会继续努力。谢谢大家。“

还好,我还可以谈着未来一个月的计划。我还可以轻轻合上眼,把这终将结束的美梦给做完。

然后,迎接告别。

初三的最后一天,全班浸没在泪水中,而我却木讷而不解地看着其他人。

大四的最后几晚,宿舍里的四个人默契地通宵玩着联机游戏。

下个月,我会一边告别,一边收拾行装。之后,我会转过身去,独自远离这令我看过无数美景的高楼。

公司,不过是又多了一份离职记录;上海,不过是又冷漠地送走了一位外乡人;亚洲大陆,不过是又见证了一次飞走的航班。

而我,又永远失去了一段生活,又多了一段新的生活。

阅读理解环节

  1. 如何理解标题“合眼告别美梦”?(14分)

    答:“美”,表示这段经历对作者来说十分享受(2分)。“梦”,表示作者终将告别这样的生活(2分)。用“合眼”而不是“闭眼”,更强调睁开眼又再次闭上眼睛的动作(1分),表达作者已经意识到这段经历已经步入尾声(1分),却依依不舍的心理(2分)。“告别”,是作者主动进行的,体现了作者抛弃旧生活,迈向新生活的决绝(2分)。标题虽然只有短短六字,却把作者对过去生活的赞美、对意识到一切终将过去的苦恼、对告别过去的不舍、对不再留恋过去的坚定这些复杂的感情都写了出来,可谓是妙笔生花(4分)。

  2. 文章中提到了结婚。结婚之后,竟然只是”租一个大一点的房子“,几年后才买房。没有房子,哪结得了婚?这里逻辑有没有问题?

    如果有人觉得这里逻辑有问题,那么说明这个人的心态已经不再年轻,已经完全融入这个无情的社会了。这个时候,你不应该去去关心我的文章写得有没有逻辑问题,而应该反思一下,自己什么时候开始心态发生了变化,什么时候把结婚和买房绑定到了一起。当你想通这些问题的时候,你一定已经收获了很多,而不会再去在意我文章写得有没有问题了。可以说,我这个地方故意要这样写,故意要去钓鱼,故意勾起读者对于自己的反省。

  3. 文章里提到的”开源“,”代码库“是什么意思?

    我们见到的程序,都是从源代码里生产出来的,就和食物是根据配方生产出来的一样。一般公司不会提供源代码。而”开源“,指公开一个项目的源代码。”代码库“,一般指 GitHub (放代码的网站)上开源出来的代码项目。

    补充资料:公元2021年,天才开源项目开发者周弈帆作为元老级开发者,参与了著名开源项目 MMDeploy 的开发。该项目成功减少了开发人工智能应用的成本,为后来全世界的人工智能化革命埋下了重要的伏笔。

做为天才卡牌游戏玩家兼设计师,我十分热爱卡牌游戏,经常向他人安利我喜欢的卡牌游戏。

一天,我的好朋友和我讲:“最近出了款叫《邪恶冥刻》的卡牌游戏,融合了”炉石”、”万智牌”、”游戏王”的玩法,还有《杀戮尖塔》中的Roguelike机制,强烈推荐你去玩。”

“这是款对称卡牌游戏吗?”做为资深卡牌游戏玩家,我立马提出了这个问题。所谓对称卡牌游戏,就是每个玩家的规则都是一样的,用同样的方式击败对手。而非对称卡牌游戏一般出现在PVE(人对战电脑)中,玩家一般使用卡牌作战,而boss只有血量和技能,不使用卡牌战斗。

“是的,但这款游戏做得很好。“朋友看出了我似乎有所顾虑,依然极力向我推荐着这款游戏。

我将信将疑地去体验了这款游戏,结果不出我所料——游戏的平衡性出了大问题。

最近我准备简单点评一些游戏。虽然我的点评可能会极度专业(我相信这个世界上没有多少人能提出像我这样专业的评论),且以批评、改进意见为主,但在介绍游戏的简况和优点时,我会用尽可能用通俗易懂的方式表达,让游戏经验较少的人也能读懂。《邪恶冥刻》给人的体验很糟糕。就像你去一家中端的饭店就餐,里面的装修、服务不输高档饭店。服务员热情地给你端上一块精美的一人餐蛋糕。虽说是一人餐,蛋糕上放满了琳琅满目的水果和巧克力。你舔了一口奶油,不禁感叹道:”太美味了!“你连忙又吃了几口,却发现这蛋糕越吃越没有味道。吃完之后,你发现肚子还是半饱。于是,你默默抱怨道:”有这个钱去装修饭店,不如在菜品上多下点功夫。“

在我看来,《邪恶冥刻》是一款十分可惜的游戏。作者很有实力,有丰富的游戏制作经验。他做出了一款画面表现力强的大杂烩游戏。刚玩一会儿,你会觉得游戏的各个机制都很吸引人。但是,游戏的平衡性极差,你很快就觉得游戏没有挑战性了。玩完了一个又一个看起来很有新意的关卡后,你用十来个小时就通关了整款游戏,看着作者花了不少时间做出来的演出(即玩家不能交互,而是像看电影一样看游戏剧情),感到索然无味。整款游戏可以用虎头蛇尾来评价,甚至是”虎头蛇身“。作者明明有丰富的游戏设计经验,却浪费了很多时间与资源去做冗余的游戏机制和演出,真的很令人惋惜。整款游戏的创意运用得好的话,这将是一部足以记入卡牌游戏历史的游戏。可能是因为卡牌游戏本身的平衡太难掌握了吧。

下面我将按照老规矩,先介绍游戏的亮点,再详细列出游戏的缺点。

本文有一定剧透,虽然游戏不是由剧情主导的,被剧透也没啥关系

卡牌游戏大杂烩

”我的回合!抽取一张松鼠牌!献祭一张松鼠,召唤一张狼崽。狼崽会在下一回合,变成3攻2血的狼,并在回合结束时朝前方自动攻击。失败的天平,会向你那边倾斜3点。“

”战斗胜利,我选择移动主角,朝左边的路前进,在篝火处升级我的狼崽。现在狼崽的生命,从1点变成了3点。”

如果玩家需要在游玩的同时描述游戏内容,我一定会这样进行解说。

有经验的卡牌游戏玩家在看完上面两段描述后,一定会露出会心的微笑:《邪恶冥刻》借鉴了许多游戏,包括”游戏王“的献祭系统、《炉石传说》的攻击与血量、《Artifact》的自动攻击、《杀戮尖塔》的Roguelike卡牌系统。

加入游戏机制是一件简单的事情。但难能可贵的是,《邪恶冥刻》把各个机制结合起来,创造了一套小而精的卡牌游戏规则:说它小,是因为游戏战场小,卡牌数量少,战斗结束得块;说它精,是因为游戏规则有趣且自洽。

小的游戏,做精自然容易。但这样的游戏往往会面临游戏内容不足的情况(比如《陷阵之志》(”into the breach”),体量小而近乎完美)。作者似乎意识到了这一点,他用一种特别的方式来添加游戏内容:通过大幅度改变游戏的玩法来创造新的关卡。

游戏系统大杂烩

除了最初的Roguelike卡牌系统外,作者在保持核心玩法是卡牌对战的前提下,还引入数个不同的游戏系统。”像素画面“、”开卡包“、”向上下左右四个方向拓展地图的塞尔达式2D地图“。这些本不可能在同一部游戏中出现的元素,奇迹般地出现在了这同一部游戏里。作者的初衷算是达到了——随着游戏内容的增加,玩家的游戏时间确实得到了延长。

除了一些常见的”对战“、”冒险“游戏机制外,《邪恶冥刻》还把解谜要素(在我的定义里,”解密“指打开隐藏砖块、用钥匙打开门、发现boss弱点这种轻度的需要玩家动脑的游戏机制,”解谜“则指独特、高难度的谜题机制,需要玩家认真思考,这些机制一般是游戏的核心玩法)融入了游戏的所有章节里。在完成卡牌游戏的挑战之余,玩家一定要通过解谜来推动游戏的进度。这些解谜没有冗余之感,是玩家在激烈的卡牌对战过后的完美调剂品。

在一部游戏中塞入数个大的游戏系统,这是一般的游戏不敢做的事情。也只有有经验的独立游戏作者,敢于在自己的作品里炫技。很难得,这些游戏系统融合得不错,过度没有很突兀,值得游戏经验不是很丰富的设计师学习。

成也卡牌,败也卡牌

卡牌游戏的特点是什么?

从玩家的角度出发,卡牌游戏最大的特点就是趣味性。简单的规则,狭小的战场,就能演绎出一部部精彩的对局。如前文所讲,得益于卡牌游戏本身的特性,《邪恶冥刻》设计了一种简单而趣味性极强的卡牌游戏机制。如何打出强力卡牌,怎么摆放卡牌,怎么样造成有效伤害……卡牌游戏的每一个机制都引入了思考。在一切都确认完毕后,玩家就能双手离开键盘,看着卡牌自动攻击,并触发卡牌效果的联动,最终获得游戏的胜利(或者翻车),体验着思考带来的成就感。(关于卡牌游戏的认真研究将会出现在以后的文章)

但从设计师的角度来看,卡牌游戏的设计难度是极大的。卡牌本身具有的随机性,加上卡牌间难以量化的联动效果,让卡牌游戏的平衡性成为了一个难题。《邪恶冥刻》主要就输在了平衡性这一点上——游戏在后期实在太简单了,战斗胜利丝毫不能给玩家带来乐趣。

越是机制精致、联动性强的卡牌游戏,越能给玩家带来耳目一新之感,也越难维持平衡性。这真是一件矛盾的事情。

这里将先分章节,再从宏观到具体,逐条列出我还能记住的游戏平衡性上的具体问题。这些内容是写给玩过游戏的玩家看的。

总体

游戏机制

  1. 血量差为5点的胜利条件,对于先手行动的玩家来说,过于容易触发。反过来,boss太强,玩家就会被秒杀。这是游戏的核心机制,也是一切平衡问题的根源,确实不好改进,不然游戏就完全变了。一些可能的改进意见会在后文提及。
  2. 缺乏对手牌臃肿的惩罚。一般情况下,卡牌游戏要求卡组精简,不然抽到关键牌的概率会缩小,卡组整体强度降低。做为一个每回合抽一张卡的游戏,理论上卡组臃肿带来的影响是很大的。但因为第1条胜利和失败都过于简单,为了让玩家在前几回合不被秒杀,作者在设计时让对手的卡组进攻性不至于太强,其结果玩家卡手几回合都没什么关系。这无论在单机卡牌游戏还是多人卡牌游戏中都是不允许的。
  3. 缺乏抽牌上的联动。这一条导致了第2条问题,玩家容易卡手,对手强度不敢太强,玩家卡手反而不被惩罚。
  4. 对手下回合策略已知。这条由《杀戮尖塔》发扬的机制是回合制游戏的一项历史性突破,我非常喜欢它,且赞同这条机制出现在卡牌游戏里。但无疑这条机制令游戏的难度又降了一层。
  5. 游戏中,有”站场“这个概念,即上回合生存的生物,下回合还能进行攻击。这是一条滚雪球的机制,有场面的玩家会一直扩大优势。而对手对于场面的控制极差。这导致玩家控制场面后很容易获得胜利。

细节

  1. 部分卡牌效果过强。如”检索牌库中的一张牌“。

第一章

游戏机制

  1. ”检索牌库中的一张牌“与无限资源之间的组合过于BT。比如令所有松鼠牌具有”检索“效果,这等于让玩家自由地控制手牌,所有战斗都失去了难度。作者保留这些强大的组合,可能是想让玩家觉得游戏很有趣。但是,还是那句话,卡牌游戏是以难度恰到好处的战斗为核心。一旦玩家无敌了,后面的战斗都是垃圾时间,有什么趣味可言呢?
  2. 强行打脸牌过弱(飞行牌),这导致防守牌(防空牌)也没有价值了。可能作者想设计出面对对面的打脸牌,玩家可以用防空牌来防御,也可以用打脸牌还击这样的充满抉择的游戏体验。但是,战场就那么点大,血就那么多,战斗一下就一边倒了,这种抉择根本体现不出来。如果战场更大,血量更多,维持血量差的博弈才会更加明显。
  3. 高费牌都难以打出,尤其是骨头牌。
  4. 种族机制联动不足,一旦联动了又过于强大。

细节

  1. 剧毒秒最终boss很有趣,但平衡性因此更加差劲了。
  2. 第三关boss难度实在太低了,这是很明显的设计水平有问题。第二阶段,玩家至少可以拿走对方一张牌,而且玩家场面上是有牌的,至少有一张牌可以直接攻击到对面。玩家轻松就可以在第二阶段一开始秒杀boss。
  3. 几大终极奖励的强度明显不一样。”每回合抽两张牌“、”抽牌变成检索“无疑是超强的能力,而”战斗开始获得8个骨头“的强度太低了。

第二章

游戏机制

  1. 献祭机制配合几乎无消耗的骨头机制过强。高费献祭牌太容易被召唤了。
  2. 高费骨头牌太难召唤了。
  3. 宝石机制(万智牌机制)的强度过低。
  4. 我现在根本记不住boss的卡组了。boss的卡组实在太弱了,且后面的boss不比前面厉害多少,我前期随便组的一套卡组就能打过所有的游戏内容。我虽然不能说出boss卡组设计上具体的问题,但很明显boss的卡组强度过低。

细节

  1. 游戏的一场战斗是可以无限次进行的,这导致玩家可以获得无限多的金钱,进而获得无限多的卡牌。”能无限获得的资源,必然要有一个能无限输出的途径“,这是我在小学的时候就领悟的游戏设计原则。在这样一个卡牌游戏里,能无限获取卡牌就是不合理的。玩家可以刷无限的牌,让游戏的攒卡机制报废。虽然这游戏的平衡实在太烂,战斗太简单,玩家根本不需要去刷钱就能获取胜利。
  2. 对应游戏机制的第1条,有张无消耗的牌,效果是能用一根骨头换一个骷髅。这张牌能瞬间产生大量的祭品,让高费献祭牌登场。这牌强度太高了。

第三章

游戏机制

  1. 游戏流程过长,后期各种卡牌效果联动、堆叠,整套卡组的强度已经爆表了,多强的boss都打不过玩家。
  2. 自定义卡牌很有趣,但这导致了第1条的效果堆叠问题。剧毒、顺劈、回手,这样一张无限资源的牌一旦摸到,游戏直接胜利。
  3. 电路通路的太难触发了,而且到了游戏后期,其他牌已经过强了,这个机制变成了鸡肋。

细节

  1. 有一个给卡牌加1攻,但卡牌死亡后永久被移除卡组的效果。这个效果的设计水平很低。这个效果的本意是加强一张卡,但要冒风险。但是,加1攻并不是多大的提升,反倒是移除一张卡能够提升整套卡组的质量。设计的本意很难达成。
  2. 有一张低费卡,能够对初次出现的敌人造成1点伤害。这张牌效果的强度太高了,比不少高费牌还强,直接让防守变得特别简单。

提升平衡性的建议

游戏小而精的机制,令游戏平衡性很难提升。但在对卡牌游戏设计有诸多思考的我看来,在核心机制不变的前提下,游戏的平衡性仍然有很多提升的余地:

非对称性

关于PVE卡牌的对称性上的思考,是我在比较《炉石传说》冒险模式和《杀戮尖塔》之后进行的。《炉石传说》本来是PVP游戏,凭借着玩家间的博弈,游戏的平衡性天然能够得到维持。所有卡牌都是为聪明的人类玩家设计的。但在炉石的PVE中,AI的水平很差,游戏的平衡性直接就崩溃了。由于游戏的胜负极大取决于对战双方的水平,哪怕给AI塞很多好卡来强行提升PVE对战的平衡性,玩家还是很难从PVE中获取足够的乐趣。反观《杀戮尖塔》,整套游戏就是基于非对称战斗的。只有玩家在使用卡牌,怪物只会按照简单的规则放技能。玩家不和对手博弈,只需要最大化自己卡组的能力即可。非对称的战斗,绝对更加适合PVE卡牌游戏。

坚持使用对称卡牌机制是造成新游戏规则无法拓展、难做平衡的最重要的原因(所以说在玩游戏之前,我就很在意这是不是一款对称性卡牌游戏)。在《邪恶冥刻》中,游戏机制可以向非对称的方向修改。玩家的操作可以一直用卡牌来表示,但是敌人的表达形式可以多种多样。事实上,游戏中就有一些非对称的战斗:战斗的胜利不再是使血量差大于5,而是杀死特定的敌人。朝这个方向做就对了呀!

血量与场面

自始至终,游戏的胜利条件都是血量差大于等于5点。“血量差”这个概念令游戏的攻守之间的抉择非常有趣。但是,很明显,5点的血量差非常容易产生,boss战可能顷刻之间就结束了。

提升胜利所需血量差是一个眼见的提升平衡性的手段。但如果无脑提升血量,并不能改善游戏的平衡性。游戏的胜利条件表面上是血量占优,但实际上只要控制住场面,再大的血量差都能产生。也就是说,游戏真正的胜利条件,是取得场面的胜利。

在传统有卡牌战场的游戏里,血量这一机制的设定,其实是给游戏新增了一个胜利条件:我可以靠控制住场面,慢慢打死你;也可以通过快速造成伤害,在你的怪物还没召完前把你“抢血”抢死。这对应了传统卡牌游戏的控制卡组和快攻卡组。在《邪恶冥刻》中,设计师可能是为了让游戏多一种胜利条件,让快攻卡组可行,才令血量差这么小。

可惜,游戏的胜利条件设置得太糟糕了。作者也注意到5点伤害太容易打出,所以给boss加了很多“嘲讽”(令对方单位无法直接对我方角色造成伤害)怪物。这反过来使玩家的快攻卡组几乎不可行了。血量差5点的设定,讽刺般的起到了反效果,游戏只剩下了控制场面这一种赢法。

血量差5点是一个核心机制,几乎所有卡牌都围绕这一点做平衡,要改起来恐怕很难。但如果让我来对游戏修改的话,我会在游戏初期保持5点血量差的胜利条件,前期的弱卡以此为平衡标准进行设计。随着游戏进行,血量差的要求会逐渐放大,后续卡牌强度也逐渐增加,以新的血量差为平衡标准。

但我不能保证这种修改是有效的,实际的平衡性还需要在测试中确定。可以想到,由于胜利条件是血量差而不是血量,玩家可以通过不断进攻来代替防守,使得防守变成了一个很没有意义的事情。想把这个机制下的平衡做好还是太难了。游戏做得小而精,就不得不面对这种平衡性上的重大挑战,这种游戏还是太难设计了。

技能卡

游戏设置了许多新颖的机制,但令人意外的是技能卡(相对于怪物卡的概念,能立刻产生效果,用完立刻丢弃)这种基础机制竟然没有出现在游戏里。大概能够猜到,引入技能卡的话,游戏的平衡性将会更难维持。而且电脑的AI很不好写,想产生势均力敌的对称性战斗更难了。

如果让我做的话,无论会引入多少新的困难,我都会加入技能卡的设定。技能卡是对游戏机制做加法,而且是对游戏内容维度上的提升,可以极大扩充游戏的设计空间。也就是说,设计师有更多的空间去设置卡牌的能力,更容易设计出不一样的卡来。只要把游戏最基础的平衡性搭好,后续添加新卡、维护新卡的平衡性都会简单很多。

但估计还是出于维护那小而精的游戏机制,设计师没有向游戏里添加技能卡的设定。反正我是认为加入技能卡能大幅提升游戏的可玩性、平衡性。

手牌资源

卡牌游戏一大核心、一大可玩之处,就是抽牌系统。玩家牌库中哪怕有再强的牌,手牌数量不够,或者因为卡组臃肿抽不到想要的牌,都会导致当前的战斗陷入窘境。抽牌系统是卡牌游戏必须要做好的一点。

理论上,卡牌间不仅要有场面上的交互机制,还应该有手牌上的交互机制:比如什么有某一种族就抽一张牌,抽一张同名牌等。玩家需要动脑去最大化抽牌效果。《邪恶冥刻》的手牌资源控制得极为糟糕:要么是无脑强而无聊的手牌资源机制,什么消耗一根骨头获得一张白板卡,什么亡语把卡牌回手,什么去牌库中检索一张牌;要么这张卡就和抽牌一点关系都没有。连”抽一张牌“这种再基础不过的效果都没有。设计师根本没有花心思在抽牌系统上,根本没有去想怎么样构建一套好玩的抽牌体系。

没有考虑手牌资源,导致玩家的牌库很容易臃肿;牌库臃肿,玩家就容易卡手;玩家卡手,如果boss强度过大,玩家一两个回合就撑不住了。设计师很明显发现了这个问题,所以根本不敢给boss太强的卡组,以照顾那些很容易卡手的卡组。这让游戏的平衡性崩溃得一塌糊涂,让卡牌游戏的一大玩法——构筑精简的卡组彻底消失。

让我来做的话,一定会考虑加入更多与手牌资源有关的机制,且和现有的种族、场面等机制结合起来。但同理,加入这些设定会让游戏发生翻天覆地的变化,很多东西都要重新设计。

平衡性总结

我玩完游戏已经一个多月了,但我随手一列,还是能从机制和细节上列出这么多平衡性问题。可以看出作者对于卡牌游戏平衡性的把握是如此糟糕。这要放到一个PVP卡牌游戏中,设计师早就被玩家喷死了。

成也卡牌,败也卡牌。基于有趣的卡牌游戏框架,设计师设计了一个简单的卡牌游戏系统,这个卡牌游戏玩个几盘感觉还不错。但是,设计师必须为这样一个简单的系统付出代价:这样的系统的平衡性非常难做,很难往里面添加有趣的新卡。糟糕的平衡性,会极大拖累游戏的可玩性。这样一个卡牌游戏系统,撑死了就只能支持10来个小时的游戏时间。等玩家稍微熟悉整套系统后,这个卡牌游戏就一点也不好玩了。

丰富性与平衡性的讨论

这一节,我就不理性地进行分析,而是发表下我个人的看法了。

我喷了这么多平衡上的问题,也夸过游戏在丰富性的创新。综合而言,我对设计师的这种设计思想极度不满,对这部作品感到十分惋惜。

你说你做一个小体量的卡牌游戏,游戏机制差不多自洽。哪怕内容不多,几小时就玩完了,玩家体验尚可。我会认为这游戏非常有新意,做得很值得学习。

你说你做了一个小体量、平衡性极差,又臭又长的游戏,我会说这是个垃圾游戏,不用去玩。

问题是《邪恶冥刻》在初期以一个新颖的卡牌游戏系统吸引了玩家的眼球,后来又用这个差劲的系统浪费了玩家很多时间,同时炫技般地不断加入丰富多彩的游戏系统。不管是大一点的构筑/roguelike/有限资源这样的游戏资源系统,还是爬塔式/塞尔达式/自由式的地图系统,还是小一点的自制卡牌、读取玩家电脑信息、修改卡牌、临时添加战斗机制等一系列在现有游戏机制上的装饰,都做得十分丰富。设计师真的是设计水平很高。这要换一个游戏框架,比如换一个2D平台游戏,游戏一定会非常好玩。

但可惜,这就是一个卡牌游戏。卡牌游戏的趣味的核心,是恰到好处的难度。这种靠平衡性吃饭的游戏,就是考验设计师的硬实力,怎么样在设计好的游戏框架下,添加合理的游戏内容。加再多花里胡哨的装饰品,试图在游戏框架上扩充,而不去认真把框架内的东西填充好,是无法提升游戏的平衡性,无法做出一个足够好的游戏的。这就好比你写作文,内容乱七八糟,你说“我字写得很好看”;你写程序,逻辑混乱不堪,你说“我变量名取得好,注释写得清楚”;谈个恋爱,你长得不帅,没有钱,不会说话,你说“我程序写得很好”。这有用吗?最核心的评价指标达不到,其他的东西做得再好,有什么用呢?

我不会推荐,甚至会大力阻止别人去玩这款游戏。不是因为我不喜欢这款游戏。我从这个游戏里学到了很多东西:无论是好的设计思路,还是需要规避的缺点。我也十分认可设计师本身的水平。但是我太喜欢这款游戏了,以至于我会不断想象这款游戏如果是我做出来的会怎么样。如果是我做出了这样的游戏,我会非常难过:明明很有水平,也很有想法,却做得这么不好玩。这比做出了一款纯粹的烂游戏更加令人心疼。我这种矛盾的心理,令我对《邪恶冥刻》给出了极低的评价。这事关一个艺术家、一个设计师的尊严:费尽心思却做得有失水准的东西,宁可扔掉,也不该拿出来展示。

“《密特罗德》在Switch上出新作了?”

第一次见别人推荐这款游戏时,我还在纳闷:“这什么游戏啊?听都没听过。”但我上网一查,竟发现这游戏还有另一个家喻户晓的翻译:《银河战士》。

现代的一些2D平台跳跃类游戏会叫做“银河城”游戏,这是因为这些游戏的创意都源自于两个元老级游戏系列:《恶魔城》与《银河战士》。这类游戏有一种特点:场景中往往会设置一些初期无法通过的道路,勾起玩家的好奇心。随着玩家的能力不断解锁,玩家能去的地方越来越多,会在探索中不断满足着好奇心。我玩过早期的《恶魔城》,并没有发现这些特点。那么,这些特点肯定是来自于《密特罗德》了。抱着无限的期待,我开始了《密特罗德》最新作——《密特罗德:生存恐惧》的游玩。

熟悉的陌生人

在2D平台游戏中,有一个常见的设定:主角宽高比一般是1:2。高度为1的地方,主角是无法通过的。这一来自《超级马里奥》1代的设定大大增强了游戏的解密性:对于玩家来说,主角的身高是有意义的,玩家可以通过路的高度和主角的高度来判断这段路能不能通过,并且玩家可以通过改变主角的身高,来通过原来可能无法通过的道路。

游戏刚开始没多久,我就看到了许多这样的高度为1的”洞“,洞后面有宝物。而主角尚未解锁能够改变自己身高的能力,这些洞目前是无法通过的。我一下就反应过来,未来主角可能会获得缩小的能力。《星界边境》中,主角有一个变身成小球的能力,是不是这个能力的创意是来自《密特罗德》呢?果不其然,玩了一段时间后,主角能变身成小球了,之前的小洞全部都能通过了。

说来奇怪,这是我第一次玩《密特罗德》系列的作品,但里面的游戏元素我却非常熟悉。从我小时候在4399上玩的一个完全照抄密特罗德的叫做”魂斗罗5“的flash小游戏,到现代的《盐与避难所》、《空洞骑士》、《死亡细胞》、《星界边境》,每一个游戏都有《密特罗德》的影子。发现的熟悉元素越多,我就越惊叹于《密特罗德》的伟大——这就是足以称为元祖的2D平台游戏啊!

抛开变小、二段跳这些具体的机制不谈,从本质来看,《密特罗德》像任天堂的其他作品一样,把解密要素完美地融入了一个动作游戏里。而特别地,《密特罗德》把2D平台游戏的解密性给发扬光大了。正如前文所讲,先给玩家看一些初期无法通过的道路,再让玩家不断获得能力,探索初期无法探索的区域。这种像推理小说一样的先设悬念,再让玩家参与解密的手法,极大拓宽了玩家的游戏体验。

除了解密性上的突破,《密特罗德》还在地图设计上给后来的游戏带了一个好头。随着主角能力解锁,道路不断打通,初期的路与后期的路联通在一起,构成了一个复杂却有序的地图网。相比从头到尾只有一条线性道路的设计,这不仅减少了玩家跑图的时间,更让地图有了一种”艺术感“。这一创新被更多的游戏所吸收,我相信《黑暗之魂》这样的3D游戏的地图设计也是受到了《密特罗德》的启发。

玩到最后,《密特罗德:生存恐惧》也没有带给我什么新鲜感,但这让我更是对这个游戏系列肃然起敬:《密特罗德》的元素已经融入了现代游戏中的各个角落。

缺点

夸完了优点,这里直接来说《密特罗德:生存恐惧》的缺点。明明这是一个很成熟的游戏,却在各个层面上出现了设计上的缺陷,实在是不应该。真的是“让我做都能做得更好”。

游戏性

先从宏观的游戏性层面来讲。

  1. 奖励道具的鸡肋。我去玩了下初代FC上的《密特罗德》,当时飞弹是一个很强力的道具,但一开始主角的飞弹储备有限。因此,提升飞弹储备上限是一个重要的提升,可以看成是质变。在最新版的《密特罗德:生存恐惧》中,依然把飞弹储备上限当成奖励。但在这一部作品中,飞弹数已经绰绰有余,提升飞弹上限基本无法提升角色的战斗力。有的时候,玩家辛辛苦苦解谜越过了某一障碍,却得到了一个鸡肋的奖励,心情上肯定是大受打击。类似地,主角的生命值也很高,提升生命基本没什么收益,游戏还很吝啬地把1/4个提升生命上限的道具放在一个极难的挑战后面。总之,奖励道具的鸡肋让玩家的探索欲大幅降低。这一条缺点的本质是游戏机制不够丰富,能够给玩家的奖励太少。说得难听一点,这就是设计师不思进取,吃老本,不去引入新的设定。
  2. 主角后期能力过强。一开始,主角能开一管枪;后期,主角能开三管枪,子弹能贯穿敌人,甚至还能穿透地形。玩到最终boss前就更离谱了,主角碰到小怪就能直接秒杀,像捡到了无敌道具一样。这样路上的小怪就失去了存在的意义,玩家赶路的时候会很无聊。这可能不是一个缺陷,而是一个特性:设计师希望玩家在后期不用顾虑小怪,而是能专心收集地图中的隐藏道具。但正如第1点所讲,游戏根本无法让玩家产生收集增益道具的欲望,这个特性是很失败的。主角能力过强,从本质上看,是小怪缺乏多样性、缺乏难度,总之就是各个方便都设计得极其糟糕。如果小怪的强度能够跟上主角能力的提升,那根本不会让玩家产生能力过强的感觉。《密特罗德》的小怪设计已经被各个后来者完爆了。
  3. 一次性解密道具。《密特罗德》的一大核心玩法是不断获得新能力、新道具,打开原来无法通过的障碍。理想情况下,新能力除了能让主角通过障碍,还能提升主角的行动能力或者战斗能力,在各个场合都能发光发亮。但是,《密特罗德》中,很多新能力就是用来一次性打开一扇门的,用过一次后以后就再也用不到了。这种“工具人”般的新能力,就和最原始的迷宫游戏中的钥匙和门一样:拿到钥匙,打开门,钥匙没用了。这种设计太糟糕了,倒不是说这种设计会影响玩家的体验,而是这些冗余的游戏机制令游戏性的“美感”大大降低。

游戏内容

  1. 怪物设计水平不高。前面已经提过了,小怪的设计有很大的问题。这部boss的设计倒是还过得去。但是,游戏中有几场精英怪,怪物的动作是一模一样的。本来在这种游戏中打大怪就是一个背板的过程。第二次碰到同样的怪时,玩家已经记住了怪物的招数,打起来也没什么意思了。这里既可以说是缺乏设计水平,也可以说是毫无诚意,拿同样的内容来糊弄玩家。
  2. 游戏流程过短。这可能不算一个缺点,只能说游戏只做了这么长。问题是这个作品有一些赶工的嫌疑,几乎每个道具都解锁一个主角的功能,但有一个道具一下解锁了三个功能,突然主角就变得无敌了。如果不是赶工,就是这样设计的,那么这样做也很不好,游戏的体验突然出现了断层。

实现细节

实现细节的缺陷指的是游戏操作、UI等细节中一些不合理的地方,这些小细节往往会大幅降低玩家的体验。这些缺陷能够在程序、美术层面轻松改掉,而不会影响整体的游戏设计。

  1. 游戏中有一个机制,主角可以蓄力,再进行大冲刺。蓄力完成后是有时间限制的,过了几秒后玩家得重新蓄力。因此蓄力完成时,主角的身体会变色,以提升玩家蓄力是否存在。问题是,蓄力完成时主角会闪紫光,后期主角的服装也是紫色的,主角跳起来身上还会闪光,玩家根本无法分辨出主角是否保持了蓄力完成状态。在某些情况下,是否保持蓄力的信息是十分重要的。反正这给我带来了极差的游戏体验。40年前FC《魂斗罗》1代都能通过闪烁红蓝两色来来提示玩家主角是否处于无敌状态,现在这个作品这么简单的一个UI提示都做不好,不知道UI团队在干什么,也不知道他们是怎么测试的,为什么测试的时候没有发现这个问题。
  2. 改不了键位。这没有任何辩驳的余地,设计师和程序员都得背锅。连改键位的自由都没有,不习惯这个键位的玩家要被气死。尤其是这种有跳跃键的游戏,玩家很可能已经习惯了用某个键跳跃。好不容易习惯了这个游戏的键位,玩之前熟悉的游戏还得在把习惯改回去。
  3. 蓄力跑的时候,要按下滚轮键,再移动滚轮。问题是按着滚轮键移动滚轮的操作手感实在太差了。还有很多和蓄力跑有关的按键设计,体验极其糟糕,不去一个一个提了。

总结

其实《密特罗德:生存恐惧》整部作品的质量还是过得去的,要实现游戏中精妙的地图设计,需要花很多心血来安排道具、障碍物的位置。除了流程过短,令人意犹未尽之外,游戏没有太根本的问题。但我昨天被这个游戏的垃圾操作设计给气到了,于是认真总结了一下游戏的缺点。看得出来,有的问题是开发预算、时间导致的,有的问题是设计师水平导致的,有的问题是测试不够导致的,有的问题是欠缺细节的打磨导致的。做为一个成熟的游戏开发公司,很多错误都范得很不应该。实在是很可惜,这款元老级游戏已经风光不再,很多地方的设计水平都被《空洞骑士》等后来者超过。只能说爱之切,责之深。可惜游戏设计师看不到我的评论,只能希望我以后做的游戏不要出这些问题。

猛然从梦中惊醒后,我第一眼看到的是从窗外射入的阳光。“完了,不会睡过了吧?”我心里一惊,赶忙翻身拿起床头的手机。还好,离闹钟响起还有十分钟。

随便吃了点早饭,我就连忙骑自行车前往公司。虽然离约定的上班时间还早,但毕竟这是第一天,迟到了可不太好。

办完各种手续后,已经快到中午了。我认识了组里带我的前辈后,便和同事们一起去楼下食堂就餐。

傍晚,去对面的商业街就餐归来,已经到了理论上的下班时间了,可同事们大多没有要离开的意思。第一天刚来,我也没有要紧的活,和前辈打了声招呼后,我小心地离开了公司。


猛然从梦中惊醒后,我连忙翻身拿起手机,发现闹钟已经响过几次了。我来不及吃饭,立马骑车赶向公司。

我以最快速度骑行着,好不容易在绿灯结束前过了一个路口,却在下一个路口被红灯拦了下来。“真不走运。”我懊恼地想着。

到公司时,离约定的上班时间已经过去半个多小时了,而办公室里还空着不少位置。虽然已经来工作了一段时间,但我还是不太敢去适应这种松散的氛围。

中午午休时间宝贵,我和同事们还是不得不去拥挤的食堂就餐。

傍晚,我们按照惯例去外面的商业街吃饭。吃饭时,同事们谈论着陌生的话题,我只能在一旁听着。很快,一天结束了。


从梦中醒来后,我又贪心地闭眼休息了片刻。很快,闹钟响起,我熟练地关掉闹钟。我已经习惯这个点起床了。

上海的红绿灯是有规律的。大路口红绿灯的一个周期,等于小路口红绿灯的两个周期。通过了第一个大路口的绿灯后,我时而加速,时而慢悠悠地骑行,精准地在绿灯刚亮的时候通过每一个路口。

到公司时,大部分同事都到了。我已经充分适应了所谓的”弹性工作制“。

十一点半,我准时停下手里的工作,和旁边的同事说道:”去吃饭吧,去晚了就要排队了。“

傍晚,大家犹豫不知道今天该去哪边吃饭。”去地下的商业街吧。“我提议道。

晚上,关上灯,躺在床上,我渐渐入睡,等待着明天的到来。


从梦中醒来后,我翻身拿起手机,关掉了一分钟后即将响起的闹钟。

吃过早饭,我骑车去上班……

晚上,关上灯,躺在床上,我以习惯的姿势入睡,等待着同一天的到来。


从梦中醒来后,我翻身拿起手机,意外地发现今天没有闹钟。

原来是自由的周末到了。

我随心所欲地玩了两天。


又到了工作日。从梦中醒来后,我关掉了手机的闹钟。手机上的日期确实在不断地变化,但我似乎又回到了同一天。

我逐渐克服了种种的违和,适应了打工的生活。一切都是那么规律,时间如下坠一般越过越快,我几乎不再对生活产生任何新的感想。


可我的心中始终有一股难以名状的危机感。一天,和我年纪相仿的实习生和我聊道:“我们组里那个三十出头的前辈,居然已经结婚生孩子了!”我忽然联想起了一连串事情:我出生时,我的父亲也才三十岁左右;前段时间我和朋友以及他相处多年的女友吃饭,那场景就和我父亲和他的朋友夫妇吃饭一样……各种回忆在我脑中不断交织,最后连成了一条清晰的直线:或许我们的未来,已经被过去决定了。倘若一切都正常地进行下去,大家的人生轨迹都是一样的:结婚、生子,看着孩子重走一遍我们的十年苦读路;而我们自己,会成为父辈,再慢慢变成祖辈,一点一点老去。

我终于反应过来,自己一直以来在担心什么事情了。我认为有一件死亡更可怕的事情:如果人失去了活力,把一切都交给命运,任由时间推动自己的行为,那么这个人的未来已经毫无趣味了。就像我每天上班路上的红绿灯,见过第一盏灯后,就能够准确地说出之后每盏绿灯的出现时间。把注定发生的事情再确认一遍,不是很无聊吗?

人离开这个世界,是在心脏停止跳动之后;而人失去活力,却早在失去了「自我改变」的能力之后。人们总在不经意间,无法改变自己了。

我喜欢暗自观察、分析、嘲笑他人。

有的人被幼时的记忆所束缚,无法挣脱已经保持了一辈子的思维。几年前是什么样,不出意料地,几年后仍是什么样。

有的人惧怕落下,不断奔跑着。可是,在他人设下的路牌前,他们只能闷头前进,最终回到环形跑道的起点。

我就是这样傲慢地总结着他人的行为。但可笑的是,我自己也是一样的。一年前,我为驻足不前而焦虑;一年后的现在,我把工作上的劳累,当成自己努力生活的证据。从满是“成绩”、“排名”、”科研“的令人窒息的学校,到工作内容充实得令人空虚的公司,我欺骗自己说自己一直有所改变。可我内心也知道,并不是我自己在改变,我只是随着外界环境的变化,被推着移动而已。我担心的事情可能要发生了,我要失去活力,失去改变自我的能力了。

不,我决不会允许这样的事情发生!我生来喜欢玩乐,决不希望自己失去追求更多乐趣的活力!倘若人是一颗能运动的墨点,那我的人生轨迹一定不是一条笔直的直线。我的留下的轨迹,必然是一条曲折蜿蜒,难以捉摸的曲线。无论线条多短多长、多曲多折,多浅多深,我都会用我的活力,留下独一无二的轨迹。

人人都知道改变就是好事,都会在年轻时追求改变。可是,人们难以分清移动与改变。移动,可以是低头前进,可以是被拉着、被拖着,还可以是站着不动看着环境的变化。最终,人或许不是止于不愿改变,而是彻彻底底地失去了改变自我的能力了。

真正的自我改变,是在心中设好了去处后,不断看清变幻莫测的环境,不断挣脱四面八方的引力,不断驱动麻木的四肢,不断抬起头,不断在停下来后前进。改变必然是累人的、痛苦的——不仅仅是身体上的筋疲力尽,更多的是焦虑与失落,是面对不断扩大的黑暗又无法喊出声的绝望。

我知道自我改变的艰辛,但我更害怕接受就此失去活力的人生,我愿意接受接下来将面对的挑战。我很幸运,现在的打工只是一次实习,未来还有再次掌控大量时间的机会。我所处的环境还会改变,我可以借着这股力前进。我不缺用嘴就能喊出的斗志,甚至早已有了规划,我只需要鼓起勇气,迈开脚步,重启我向前奔跑的人生。

我相信,很快,就会有很多人见证我的改变。