LearnOpenGL-光照章节学习笔记

news2024/9/26 1:15:36

LearnOpenGL-光照章节学习笔记

  • 颜色
    • 创建一个光照场景
  • 基础光照
    • 一、环境光照
    • 二、漫反射光照
    • 三、镜面反射
  • 材质
  • 光照贴图
    • 一、漫反射贴图
    • 二、镜面光贴图
    • 三、放射光贴图
  • 投光物
    • 一、平行光
    • 二、点光源
      • 衰减
      • 实现
    • 三、聚光灯
      • 平滑边缘
  • 多光源
    • 一、平行光(定向光)
    • 二、点光源

颜色

我们在现实生活中看到某一物体的颜色并不是这个物体真正拥有的颜色,而是它所反射的(Reflected)颜色。换句话说,那些不能被物体所吸收(Absorb)的颜色(被拒绝的颜色)就是我们能够感知到的物体的颜色。
当把光源的颜色与物体的颜色值相乘,所得到的就是这个物体所反射的颜色(也就是我们所感知到的颜色)。

vec3 lightColor = vec3(1.0,1.0,1.0); //光是白色
vec3 toyColor = vec3(1.0,0.5,0.31);
vec3 res = light * toyColor;

创建一个光照场景

物体的片元着色器新建两个uniform变量:lightColor、objectColor

#version 330 core
out vec4 FragColor;

uniform vec3 objectColor;
uniform vec3 lightColor;

void main()
{
    FragColor = vec4(lightColor * objectColor, 1.0);
}

我们还要创建一个光源立方体,所以要为这个物体创建专门的VAO

unsigned int lightVAO;
glGenVertexArrays(1, &lightVAO);
glBindVertexArray(lightVAO);
// 只需要绑定VBO不用再次设置VBO的数据,因为箱子的VBO数据中已经包含了正确的立方体顶点数据
glBindBuffer(GL_ARRAY_BUFFER, VBO);
// 设置灯立方体的顶点属性(对我们的灯来说仅仅只有位置数据)
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
glEnableVertexAttribArray(0);

然后设置颜色uniform变量

// 在此之前不要忘记首先 use 对应的着色器程序(来设定uniform)
lightingShader.use();
lightingShader.setVec3("objectColor", 1.0f, 0.5f, 0.31f);
lightingShader.setVec3("lightColor",  1.0f, 1.0f, 1.0f);

当我们修改顶点或者片段着色器后,灯的位置或颜色也会随之改变,这并不是我们想要的效果。我们不希望灯的颜色在接下来的教程中因光照计算的结果而受到影响,而是希望它能够与其它的计算分离。我们希望灯一直保持明亮,不受其它颜色变化的影响(这样它才更像是一个真实的光源)。所以为其准备一个专门的片元着色器

#version 330 core
out vec4 FragColor;

void main()
{
    FragColor = vec4(1.0f);
}

然后根据需求分别计算物体和光源的mvp矩阵

基础光照

Phong光照模型的主要结构由3个分量组成:环境(Ambient)、漫反射(Diffuse)和镜面(Specular)光照。
关于光照模型的原理可以看 Games101学习笔记 Shading
在这里插入图片描述

  • 环境光:其他物体的光,在Phong模型中设置为常量
  • 漫反射:假设光线到物体是均匀反射出去的,模拟光源对物体方向性影响
  • 镜面光照(高光反射):模拟有光泽物体上面出现的亮点。完全反射

一、环境光照

考虑物体接受来自其他物体的反射光,叫做全局光照,我们一般把环境光照设置为常量

float ambientStrength = 0.1;
vec3 ambient = ambientStrength * lightColor;

二、漫反射光照

计算漫反射光照需要用到法线向量,可以直接在顶点数据中定义,顶点着色器也要声明aNormal以及输出变量Normal

计算漫反射光照:diffuse = lightColor * objectColor * max(dot(normal, lightDir), 0)。为了得到光源方向,我们需要得到光源位置(在片段着色器中设置为uniform变量 lightPos)和顶点位置(在顶点着色器中作为输出 FragPos变量)

#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;

out vec3 FragPos;
out vec3 Normal;

uniform mat4 model;
uniform mat4 view;
uniform mat4 proj;

void main()
{
    FragPos = vec3(model * vec4(aPos, 1.0));
    Normal = aNormal;  
    
    gl_Position = proj * view * vec4(FragPos, 1.0);
}
#version 330 core
out vec4 FragColor;

in vec3 Normal;  
in vec3 FragPos;  
  
uniform vec3 lightPos; 
uniform vec3 lightColor;
uniform vec3 objectColor;

void main()
{
    // ambient
    float ambientStrength = 0.1;
    vec3 ambient = ambientStrength * lightColor;
  	
    // diffuse 
    vec3 norm = normalize(Normal);
    vec3 lightDir = normalize(lightPos - FragPos);
    float diff = max(dot(norm, lightDir), 0.0);
    vec3 diffuse = diff * lightColor;
            
    vec3 result = (ambient + diffuse) * objectColor;
    FragColor = vec4(result, 1.0);
} 

目前片段着色器里的计算都是在世界空间坐标中进行的。所以,我们应该把法向量也转换为世界空间坐标,我们需要一个法线矩阵来进行转换,法线矩阵被定义为「model矩阵左上角3x3部分的逆矩阵的转置矩阵」,可以在顶点着色器中进行

Normal = mat3(transpose(inverse(model))) * aNormal;

矩阵求逆是一项对于着色器开销很大的运算,因为它必须在场景中的每一个顶点上进行,所以应该尽可能地避免在着色器中进行求逆运算。最好先在CPU上计算出法线矩阵,再通过uniform把它传递给着色器(就像模型矩阵一样)。

三、镜面反射

通过根据法向量翻折入射光的方向来计算反射向量。然后我们计算反射向量与观察方向的角度差,它们之间夹角越小,镜面光的作用就越大。

计算镜面反射:specular = lightColor * specularColor * max(dot(halfDir, normal)) ^ gloss
halfDir半程向量为光线方向与观察方向的角平分线方向向量,normalize(lightDir, viewDir)即可
viewDir即为摄像机位置-顶点位置

//specular
float specularStrength = 0.5;
vec3 viewDir = normalize(viewPos - FragPos);
vec3 halfDir = normalize(viewDir + lightDir);
vec3 specular = lightColor * specularStrength * pow(max(dot(norm, halfDir), 0.0), 32);

材质

如果我们想要在OpenGL中模拟多种类型的物体,我们必须针对每种表面定义不同的材质(Material)属性。我们可以分别为三个光照分量定义一个材质颜色(Material Color):环境光照(Ambient Lighting)、漫反射光照(Diffuse Lighting)和镜面光照(Specular Lighting)。通过为每个分量指定一个颜色,我们就能够对表面的颜色输出有细粒度的控制了。
在片段着色器中,我们创建一个结构体(Struct)来储存物体的材质属性

#version 330 core
struct Material {
    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
    float shininess; //反光度(Shininess)分量,影响镜面高光的散射/半径。
}; 

uniform Material material;

如果要调整每个部分的光照影响度

struct Light {
    vec3 position;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};

uniform Light light;

光照贴图

一、漫反射贴图

它是一个表现了物体所有的漫反射颜色的纹理图像。在着色器中使用漫反射贴图的方法和“纹理”教程是完全一样的。但这次会将纹理储存为Material结构体中的一个sampler2D。我们将之前定义的vec3漫反射颜色向量替换为漫反射贴图

struct Material {
    sampler2D diffuse;
    vec3      specular;
    float     shininess;
}; 
...
in vec2 TexCoords;

更新两个VAO的顶点属性指针来匹配新的顶点数据,并加载箱子图像为一个纹理。在绘制箱子之前,我们希望将要用的纹理单元赋值到material.diffuse这个uniform采样器,并绑定箱子的纹理到这个纹理单元

lightingShader.setInt("material.diffuse", 0);
...
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, diffuseMap);

二、镜面光贴图

对于木头来说,我们不想让它镜面反射,可以将镜面反射材质设为vec3(0.0),但是对于金属边框,我们可以使用专门用于镜面光的贴图。
镜面高光的强度可以通过每个像素的亮度(每个像素都可以用一个颜色向量表示)来获取,在片元着色器中,使用采样对应的颜色值乘上镜面强度

struct Material {
    sampler2D diffuse;
    sampler2D specular;
    float     shininess;
};
.....
vec3 ambient  = light.ambient  * vec3(texture(material.diffuse, TexCoords));
vec3 diffuse  = light.diffuse  * diff * vec3(texture(material.diffuse, TexCoords));  
vec3 specular = light.specular * spec * vec3(texture(material.specular, TexCoords));
FragColor = vec4(ambient + diffuse + specular, 1.0);

在主程序中,新建一个纹理,在渲染之前先把它绑定到合适的纹理单元上

lightingShader.setInt("material.specular", 1);
...
glActiveTexture(GL_TEXTURE1);
glBindTexture(GL_TEXTURE_2D, specularMap);

三、放射光贴图

是储存了每个片段的发光值(Emission Value)的贴图
直接用采样得到的像素值来附在纹理上就好,不受光照影响

struct Material {
    sampler2D diffuse;
    sampler2D specular;
    sampler2D emissive;
    float shininess; 
}; 
......
 //自发光
vec3 emissive = vec3(texture(material.emissive, TexCoords)).rgb;
vec3 result = (ambient + diffuse + specular + emissive) * objectColor;
FragColor = vec4(result, 1.0);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, emissiveMap);

投光物

将光投射(Cast)到物体的光源叫做投光物(Light Caster)。

一、平行光

假设光源处于无限远处的模型时,它就被称为定向光,光源的每条光线就会近似于互相平行,因为它的所有光线都有着相同的方向,它与光源的位置是没有关系的。

直接使用光的direction向量而不是通过position来计算lightDir向量。

struct Light {
    // vec3 position; // 使用定向光就不再需要了
    vec3 direction;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};
...
void main()
{
  vec3 lightDir = normalize(-light.direction);
  ...
}

二、点光源

光源是处于世界中某一个位置的光源,它会朝着所有方向发光,但光线会随着距离逐渐衰减

衰减

根据片元距光源的距离计算了衰减值
在这里插入图片描述
我们需要定义三个可配置的量:常数项 K c K_{c} Kc、一次项 K l K_{l} Kl、二次项 K q K_{q} Kq

  • 常数项通常保持1.0,用途是保证分母不会小于1,否则会发生距离增大而光线增强的效果
  • 一次项以线性减少强度
  • 二次项会与 d 2 d^{2} d2 相乘,二次递减

在这里插入图片描述
在这里插入图片描述

实现

需要将light.direction,改为light.position,还需要增加三个量:常数项 K c K_{c} Kc、一次项 K l K_{l} Kl、二次项 K q K_{q} Kq

struct Light {
    vec3 position;  

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;

    float constant;
    float linear;
    float quadratic;
};

假设光源照亮范围为50

lightingShader.setFloat("light.constant",  1.0f);
lightingShader.setFloat("light.linear",    0.09f);
lightingShader.setFloat("light.quadratic", 0.032f);

三、聚光灯

聚光是位于环境中某个位置的光源,它只朝一个特定方向而不是所有方向照射光线。OpenGL中聚光是用 一个世界空间位置、一个方向和一个切光角(Cutoff Angle) 来表示的,切光角指定了聚光的半径。
在这里插入图片描述

  • LightDir:从片段指向光源的向量。
  • SpotDir:聚光所指向的方向。
  • ϕ:指定了聚光半径的切光角。落在这个角度之外的物体都不会被这个聚光所照亮。
  • θ:LightDir向量和SpotDir向量之间的夹角。在聚光内部的话θ值应该比ϕ值小。
struct Light {
    vec3  position;
    vec3  direction;
    float cutOff;
    ...
};

在主程序中,直接将 cosΦ 传入。

ourShader.setVec3("light.position", camera.Position);
ourShader.setVec3("light.direction", camera.Front);
ourShader.setFloat("light.cutOff", cos(radians(12.5f)));

在片元着色器中,我们要对θ和Φ进行比较,如果 θ < Φ,那么就可以进行正常光照计算,反之就返回环境光照。要实现两个角度的比较,可以通过比较余弦值的大小(就不用耗性能的求反三角函数来得到角度)

float theta = dot(lightDir, normalize(-light.direction));

if(theta > light.cutOff) 
{       
  // 执行光照计算
}
else  // 否则,使用环境光,让场景在聚光之外时不至于完全黑暗
  color = vec4(light.ambient * vec3(texture(material.diffuse, TexCoords)), 1.0);

平滑边缘

为了让聚光灯的边缘平滑,模拟聚光灯时需要一个外圆锥(再创建一个)和内圆锥(上面的圆锥)

为了创建一个外圆锥,我们只需要再定义一个余弦值γ来代表聚光方向向量和外圆锥向量(等于它的半径)的夹角。然后,如果一个片段处于内外圆锥之间,将会给它计算出一个0.0到1.0之间的强度值。如果片段在内圆锥之内,它的强度就是1.0,如果在外圆锥之外强度值就是0.0。
在这里插入图片描述

  • ϵ=ϕ−γ,内(ϕ)和外圆锥(γ)之间的余弦值差
    在这里插入图片描述

float theta     = dot(lightDir, normalize(-light.direction));
float epsilon   = light.cutOff - light.outerCutOff;
float intensity = clamp((theta - light.outerCutOff) / epsilon, 0.0, 1.0);
.....
// 将不对环境光做出影响,让它总是能有一点光
diffuse  *= intensity;
specular *= intensity;
  • clamp函数把第一个参数约束(Clamp)在了0.0到1.0之间。这保证强度值不会在[0, 1]区间之外
  • 不需要再判断θ 和 Φ,因为已经有了强度值的限制

多光源

要进行多个光源计算,如果都放在main函数里那么会非常冗杂,可以为每种灯光设置相应的函数,然后汇总到main函数里

out vec4 FragColor;

void main()
{
  // 定义一个输出颜色值
  vec3 output;
  // 将定向光的贡献加到输出中
  output += someFunctionToCalculateDirectionalLight();
  // 对所有的点光源也做相同的事情
  for(int i = 0; i < nr_of_point_lights; i++)
    output += someFunctionToCalculatePointLight();
  // 也加上其它的光源(比如聚光)
  output += someFunctionToCalculateSpotLight();

  FragColor = vec4(output, 1.0);
}

一、平行光(定向光)

可以在片元着色器中专门声明一个定向光的结构体

struct DirLight {
    vec3 direction;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};  
uniform DirLight dirLight;

然后将结构体传入以下函数

vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir)
{
	vec3 lightDir = light.direction;
	vec3 halfDir = normalize(lightDir + viewDir);
	vec3 diffuse = light.diffuse * vec3(texture(material.diffuse, TexCoords)) * max(dot(lightDir, normal), 0.0);
	vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords));
	vec3 specular = light.specular * vec3(texture(material.specular, TexCoords)) * pow(max(dot(halfDir, normal), 0.0), material.shininess);
	return (diffuse + ambient + specular);
}

二、点光源

点光源位置、衰减…定义一个点光源结构体(需要四个点光源,在GLSL中使用了预处理指令来定义了我们场景中点光源的数量)

struct PointLight {
    vec3 position;

    float constant;
    float linear;
    float quadratic;

    vec3 ambient;
    vec3 diffuse;
    vec3 specular;
};  
#define NR_POINT_LIGHTS 4
uniform PointLight pointLights[NR_POINT_LIGHTS];
vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir)
{
	float distance = length(light.position - fragPos);
	vec3 lightDir = normalize(lightPos - fragPos);
	vec3 halfDir = normalize(lightDir + viewDir);
	float atten = 1.0 / (light.constant + light.linear * distance + light.quadratic * distance * distance);

	vec3 diffuse = light.diffuse * vec3(texture(material.diffuse, TexCoords)) * max(dot(normal, lightDir)) * atten;
	vec3 specular = light.specular * vec3(texture(material.specular, TexCoords)) * pow(max(dot(normal, halfDir), 0.0), material.shininess) * atten;
	vec3 ambient = light.ambient * vec3(texture(material.diffuse, TexCoords)) * atten;

	return(diffuse + specular + ambient);
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1988964.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

知识分享 | 详解整车区域控制器(ZCU)

随着智能网联汽车技术的迅猛发展&#xff0c;整车区域控制器ZCU&#xff08;Zone Control Unit&#xff09;作为汽车电子电气架构中的核心组件&#xff0c;其重要性日益凸显。ZCU不仅作为区域数据中心、IO中心及配电中心&#xff0c;在车辆动力、传感器管理、信息娱乐等方面发挥…

线性DP+摆渡车

线性DP&#xff1a; 具有线性“阶段”划分的动态规划算法称为线性DP。 线性DP无论状态表示是一维还是多维&#xff0c;DP算法在线性DP问题上都体现出了“作用在线性空间上的阶段划分和状态转移” &#xff0c;DP的阶段沿着各个维度线性增长。从一个或多个“初始状态”开始有方…

uni-app下Worker的使用

1、在static目录下创建一个目录用于存放worker文件 为啥要在static目录下创建worker目录呢&#xff1f;那是因为worker.js没办法在别的js中被引用&#xff0c;uni-app项目编译成微信小程序代码时会进行Tree-shaking&#xff08;摇树优化&#xff09;&#xff0c;把那些没被引用…

全方位教程:接入视频美颜SDK与直播美颜插件的完整步骤

今天&#xff0c;小编将为您详细介绍如何接入视频美颜SDK与直播美颜插件的完整步骤。 一、准备工作 开发环境的搭建 在开始集成之前&#xff0c;确保您的开发环境已配置完毕。无论是使用iOS、Android&#xff0c;还是Web开发&#xff0c;您都需要准备好对应的开发工具链&…

人工智能深度学习系列—深入探索KL散度:度量概率分布差异的关键工具

人工智能深度学习系列—深度解析&#xff1a;交叉熵损失&#xff08;Cross-Entropy Loss&#xff09;在分类问题中的应用 人工智能深度学习系列—深入解析&#xff1a;均方误差损失&#xff08;MSE Loss&#xff09;在深度学习中的应用与实践 人工智能深度学习系列—深入探索KL…

你的报表工具选对了吗?中国式报表的正确制作方法

1. 中国式报表的困境 在许多企业中&#xff0c;“中国式报表”是一个让人头疼的存在。中国式报表通常格式复杂&#xff0c;包含大量数据和多层次的数据结构。它不仅需要展示大量的统计数据&#xff0c;还要通过交叉计算等方式呈现数据之间的关系。这种报表对数据处理能力、展示…

关于hive与hadoop初了解

hdfs分布式存数据&#xff0c;MapReduce处理数据。yarn资源管理和任务调度。 hive就是方便处理hadoop数据的 。 hive本事不处理数据&#xff0c;在hive on yarn上是 将sql转换为mapreduce程序&#xff08;不太理解&#xff0c;后续了解吧&#xff09;

数据编制全攻略:从杂乱原始数据到AI就绪数据集的转化之旅

目录 什么是数据编制?数据编制的重要性数据编制的基本流程1. 数据收集2. 数据清洗3. 数据转换4. 数据结构化5. 数据集成6. 数据验证 数据编制的最佳实践1. 制定数据标准2. 自动化流程3. 版本控制4. 数据质量监控5. 文档化6. 安全性和隐私保护 数据编制中的常见挑战及解决策略1…

日志系统——异步缓冲区

生产者——消费者模型&#xff1a; 多线程场景中最常见的模型之一&#xff0c;异步写日志时负责产生日志消息的业务线程充当生产者&#xff0c;负责写日志的线程充当消费者&#xff0c;两种角色进行数据交互需要依靠一块缓冲区。 单缓冲区的缺点 传统的单缓冲区PC模型一般是基…

【工具推荐】强大的图形化“社工密码生成器”

下载地址&#xff1a; 关注WX“光剑安全”公众号&#xff0c;发送“20240807社工”即可获得工具包&#xff01; 工具一&#xff1a;安全牛&#xff0c;java运行的一款社工密码生成器。 1、工具页面如下&#xff0c;可以根据对方信息、对方配偶信息、特殊符号进行排列组合生成…

c++的STL库stack、queue的使用

1.stack 在STL库中stack是一个模版类&#xff0c;第一个模版参数为存储的数据类型&#xff0c;第二个模版参数为实现stack的容器&#xff08;可缺省&#xff09;。 常用的成员函数如下 2.queue 如stack一样queue是一个模版类&#xff0c;第一个模版参数为存储的数据类型&#…

终于解决了ubuntu在高清屏上 chromium界面dpi低,界面模糊的问题

终于解决了ubuntu在高清屏上 chromium界面dpi低&#xff0c;界面模糊的问题 说明&#xff1a; 系统是ubuntu24.04&#xff0c;用firefox dpi正常&#xff0c;界面和文字都很细腻&#xff0c;用chrome,chromium,edge就很模糊&#xff0c;网上一大片的都是说字体问题&#xff0…

(21)Spring基础

Spring 需要导入的包 <!-- https://mvnrepository.com/artifact/org.springframework/spring-core --><dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>5.3.26</ve…

Go 1.19.4 结构体-Day 09

1. 结构体介绍 1.1 什么是结构体 结构体&#xff08;struct&#xff09;是一种用户定义的类型&#xff0c;它由一系列的字段组成&#xff0c;每个字段都有自己的名称和类型。 结构体也是值类型的&#xff0c;就算加了指针也是&#xff0c;只不过是复制的内存地址。 1.2 为什么…

【数据结构】二叉搜索树(Java + 链表实现)

Hi~&#xff01;这里是奋斗的明志&#xff0c;很荣幸您能阅读我的文章&#xff0c;诚请评论指点&#xff0c;欢迎欢迎 ~~ &#x1f331;&#x1f331;个人主页&#xff1a;奋斗的明志 &#x1f331;&#x1f331;所属专栏&#xff1a;数据结构、LeetCode专栏 &#x1f4da;本系…

活动回顾|首次 Cloudberry Database Meetup · 北京站成功举办

8 月 3 日&#xff0c;由酷克数据 HashData 主办的 Cloudberry Database Meetup 北京站活动圆满结束。本次 Meetup 以“以开源应对 Greenplum 闭源&#xff0c;原厂开发者再聚首”为主题&#xff0c;深入探讨了 Greenplum 闭源所带来的影响&#xff0c;并聚焦于 Cloudberry Dat…

std::list里面的push_back和resize效率问题

2024年8月6日&#xff1a; 向list里面循环添加数据&#xff0c;两种写法 for(;;){myList.resize(myList.size()1);MyObject &obj *myList.rbegin();obj.a itervalue; } 第二种写法是push_back: for(;;){MyObject obj&#xff1b;obj.a itervalue;myList.push_back(obj)…

并发编程CompletableFuture

1. CompletableFuture简介 1.1 异步编程的概念 异步编程是一种编程范式&#xff0c;允许程序在等待某些操作完成时&#xff0c;继续执行其他任务。这在处理I/O密集型任务&#xff0c;如网络请求或文件读写时尤其有用。异步编程可以提高程序的响应性和效率&#xff0c;避免在等…

【docker】docker容器部署常用服务

1、容器部署nginx&#xff0c;并且新增一个页面 docker run -d -p 81:80 --name nginx2 nginx docker exec -it nginx2 /bin/bashcd /usr/share/nginx/html/ echo "hello world">>hello.html2、容器部署redis&#xff0c;成功部署后向redis中添加一条数据 do…

Spoon——数据库clickhouse驱动问题解决

问题 使用Spoon 软件连接clickhouse数据库&#xff0c;报错“Driver class ‘ru.yandex.clickhouse.ClickHouseDriver’ could not be found” 原因 错误消息表明Spoon无法找到ru.yandex.clickhouse.ClickHouseDriver驱动程序。这通常是因为ClickHouse的JDBC驱动程序没有正确…