基于OpenGL实现布料模拟

想要基于OpenGL实现布料模拟,我们需要这几步:

先将布料离散为粒子网格,每个网格单元由两个三角形组成的矩形表示、顶点即粒子,再在粒子间建立水平、垂直、对角及大范围结构弹簧以抵抗拉伸、剪切和大尺度变形;每帧先用上一帧的加速度对粒子做显式欧拉积分更新速度与位置,再对与地面相交的粒子做穿透修正与速度分解(法向反弹、切向摩擦),接着根据三角形面法线累加更新顶点法线用于平滑着色与风力计算,然后清空加速度并按重力、弹簧阻尼力及三角形面上的风力(分配到顶点)重新计算本帧加速度供下一帧使用,最后将顶点位置与法线写入 VBO 完成渲染,从而形成「积分 → 碰撞 → 法线 → 受力」的实时仿真与渲染管线。

接下来我们来逐个学习具体代码实现:

cpp 复制代码
for (int i = 0; i < height; ++i) {
    for (int j = 0; j < width; ++j) {
        glm::vec3 pos = glm::vec3(-0.1 * width / 2, 0.1 * height / 2, 0) +
            glm::vec3(i) * glm::vec3(0, -0.1, 0) +
            glm::vec3(j) * glm::vec3(0.1, 0, 0);
        Particle* particle = new Particle(pos, glm::vec3(0.1), 0.1);
        particles.push_back(particle);
        positions.push_back(pos);
        if (i == 0) {
            particle->setFix();
            fixedIdx.push_back(i * width + j);
        }
    }
}

我们逐个生成粒子,并用数据结构把各个粒子存储起来,这些粒子的具体坐标都是按照规律生成的,这样就可以构建出粒子网格,第一行 i==0 设为固定点并记录到fixedIdx,相当于布挂在一条边上,只有它们会随布料整体平移/旋转,不参与动力学积分。

cpp 复制代码
for (int i = 0; i < height - 1; ++i) {
    for (int j = 0; j < width - 1; ++j) {
        int index1 = i * width + j;           // 左上
        int index2 = (i + 1) * width + j;     // 左下
        int index3 = i * width + j + 1;       // 右上
        Particle* p1 = particles[index1], *p2 = particles[index2], *p3 = particles[index3];
        Triangle* triangle = new Triangle(p1, p2, p3);
        indices.push_back(index1); indices.push_back(index2); indices.push_back(index3);
        triangles.push_back(triangle);
    }
}

这里则是我们生成三角形的代码,这里是上三角,可以看到三角形的顶点由我们的粒子组成。

cpp 复制代码
for (int i = 1; i < height; ++i) {
    for (int j = 0; j < width - 1; ++j) {
        int index1 = i * width + j;           // 左下
        int index2 = i * width + j + 1;       // 右上
        int index3 = (i - 1) * width + j + 1; // 右下
        // ... 同上,new Triangle(p1,p2,p3),push indices 与 triangles
    }
}

下三角,和前者的区别就是index的不同。

每个矩形格由两个三角形覆盖,顶点全部来自粒子,不拷贝位置数据;三角形只持有三个粒子,位置随粒子更新而变。indices存的是粒子下标,用于 EBO 绘制;triangles用于法线累加和风力计算。

cpp 复制代码
	// 创建水平弹簧阻尼器
	for (int i = 0; i < height; ++i) {
		for (int j = 0; j < width - 1; ++j) {
			int index1 = i * width + j;
			int index2 = i * width + j + 1;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.1);

			springDampers.push_back(springDamper);
		}
	}

	// 创建垂直弹簧阻尼器
	for (int i = 0; i < height - 1; ++i) {
		for (int j = 0; j < width; ++j) {
			int index1 = i * width + j;
			int index2 = (i + 1) * width + j;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.1);

			springDampers.push_back(springDamper);
		}
	}

	// 创建对角线(左上到右下)弹簧阻尼器
	for (int i = 0; i < height - 1; ++i) {
		for (int j = 0; j < width - 1; ++j) {
			int index1 = i * width + j;
			int index2 = (i + 1) * width + j + 1;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.1 * glm::sqrt(2));

			springDampers.push_back(springDamper);
		}
	}

	// 创建对角线(左下到右上)弹簧阻尼器
	for (int i = 1; i < height; ++i) {
		for (int j = 0; j < width - 1; ++j) {
			int index1 = i * width + j;
			int index2 = (i - 1) * width + j + 1;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.1 * glm::sqrt(2));

			springDampers.push_back(springDamper);
		}
	}

	// 创建大范围水平弹簧阻尼器
	for (int i = 0; i < height - 2; i += 2) {
		for (int j = 0; j < width - 2; j += 2) {
			int index1 = i * width + j;
			int index2 = i * width + j + 2;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.2);

			springDampers.push_back(springDamper);
		}
	}

	// 创建大范围垂直弹簧阻尼器
	for (int i = 0; i < height - 2; i += 2) {
		for (int j = 0; j < width - 2; j += 2) {
			int index1 = i * width + j;
			int index2 = (i + 2) * width + j;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.2);

			springDampers.push_back(springDamper);
		}
	}

	// 创建大范围对角线(左上到右下)弹簧阻尼器
	for (int i = 0; i < height - 2; i += 2) {
		for (int j = 0; j < width - 2; j += 2) {
			int index1 = i * width + j;
			int index2 = (i + 2) * width + j + 2;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.2 * glm::sqrt(2));

			springDampers.push_back(springDamper);
		}
	}

	// 创建大范围对角线(左下到右上)弹簧阻尼器
	for (int i = 2; i < height; i += 2) {
		for (int j = 0; j < width - 2; j += 2) {
			int index1 = i * width + j;
			int index2 = (i - 2) * width + j + 2;
			Particle* p1 = particles[index1];
			Particle* p2 = particles[index2];
			SpringDamper* springDamper = new SpringDamper(p1, p2, 0.2 * glm::sqrt(2));

			springDampers.push_back(springDamper);
		}
	}

每个弹簧会拿两个粒子的序号(具体是哪两个看弹簧类型而定),把对应序号的粒子作为创建弹簧类的参数,丢到弹簧数组中。

cpp 复制代码
	// 更新法向量和加速度
	updateNormal();
	updateAcceleration();

	// 计算每个粒子的法向量
	for (auto particle : particles) {
		normals.push_back(particle->normal);
	}

	// 生成VAO(顶点数组对象)和VBO(顶点缓冲对象)
	glGenVertexArrays(1, &VAO);
	glGenBuffers(1, &VBO_positions);
	glGenBuffers(1, &VBO_normals);

	// 绑定VAO
	glBindVertexArray(VAO);

	// 绑定VBO - 用于存储顶点位置
	glBindBuffer(GL_ARRAY_BUFFER, VBO_positions);
	glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec3) * positions.size(), positions.data(), GL_STATIC_DRAW);
	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), 0);

	// 绑定VBO - 用于存储法向量
	glBindBuffer(GL_ARRAY_BUFFER, VBO_normals);
	glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec3) * normals.size(), normals.data(), GL_STATIC_DRAW);
	glEnableVertexAttribArray(1);
	glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), 0);

	// 生成EBO(元素索引缓冲区),并绑定
	glGenBuffers(1, &EBO);
	glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
	glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(unsigned int) * indices.size(), indices.data(), GL_STATIC_DRAW);

	// 解绑VBO
	glBindBuffer(GL_ARRAY_BUFFER, 0);
	glBindVertexArray(0);

先算初始法线和加速度,再把每个粒子的 `position`、`normal` 填入 `positions`、`normals`,创建 VAO、两个 VBO、EBO 并设置顶点属性(location 0 位置,location 1 法线)。这里的初始法线指的是粒子的法线,而粒子的法线的具体来源则是来源于三角形的面法线------我们会基于三角形的边叉积得到面法线,然后我们将这个法线方向直接给到三个顶点作为法线,这样做的理由是为了所谓的平滑着色------提高渲染后的图形的视觉效果。

以上呢就是我们来执行每帧更新之前需要的具体数据了,接下来我们来看怎么进行计算:

cpp 复制代码
	void Cloth::update() {
		// 更新每个粒子的状态
		for (auto particle : particles) {
			particle->move(0.001);  // 移动粒子
			// ... 后面是碰撞检测
		}
	}

在布料类中执行update,这里的move是执行修改粒子的位置函数,参数则是更新的时间步参数。

cpp 复制代码
// 移动粒子,更新粒子的速度和位置
void Particle::move(float step) {
    // 如果粒子不是固定的,才进行运动更新
    if (!fixed) {
        // 根据加速度更新速度
        velocity = velocity + step * acceleration;
        // 根据速度更新位置
        position = position + step * velocity;
    }
}

move函数的内容长这样,根据上一帧的加速度更新速度,再根据这一帧更新的速度更新位置。

cpp 复制代码
//这里转化下坐标,粒子位置在布料局部坐标系,地面在世界坐标系,得转换一下
		glm::vec3 pos = glm::vec3(model * glm::vec4(particle->position, 1));

		// 碰撞检测与反弹处理
		if (land->collide(pos)) {
			//碰撞了,防止下穿透,修正下
			pos[1] = land->top() + 0.01;
			//如果只改速度,不改位置,粒子已经在地面下面,下一帧还会继续穿透,最终还是抖动
			particle->position = glm::vec3(glm::inverse(model) * glm::vec4(pos, 1));

			//速度转换到世界空间,因为碰撞法线在世界空间,所以速度投影必须同空间,w=0是因为速度是方向向量,不受平移影响
			glm::vec3 v = glm::vec3(model * glm::vec4(particle->velocity, 0));
			//垂直于地面的速度(会反弹)
			glm::vec3 vClose = glm::dot(glm::vec3(0, 1, 0), v) * glm::vec3(0, 1, 0);
			//沿地面的速度(会摩擦)
			glm::vec3 vTangent = v - vClose;

			//length(vClose) ≈ 碰撞强度,自定义个0.5f的摩擦系数
			if (vTangent != glm::vec3(0)) {
				//接触越用力,摩擦越大
				float decreaseRatio = glm::min(0.5f * glm::length(vClose), 1.0f);
				vTangent = (1 - decreaseRatio) * vTangent;
			}

			//-0.1f * vClose是在计算反弹恢复系数;1就是完全弹性,0就是完全不弹,可以自己调
			//然后再把速度一转,回到局部空间
			particle->velocity = glm::vec3(glm::inverse(model) * glm::vec4(-0.1f * vClose + vTangent, 0));
		}
......
......

bool Land::collide(glm::vec3 pos) {
	// 计算地面上的一个点(地面位于y=0平面)
	glm::vec3 point = glm::vec3(model * glm::vec4(0, 1, 0, 1));
	// 地面的法向量是(0, 1, 0)
	glm::vec3 normal = glm::vec3(0, 1, 0);

	// 使用点积来检测点是否与地面发生碰撞
	if (glm::dot(pos - point, normal) > 0) {
		// 如果点在地面上方,则没有碰撞
		return false;
	}
	else {
		// 如果点在地面下方,则发生碰撞
		return true;
	}
}

float Land::top() {
	// 返回地面的最高点的y坐标
	return glm::vec3(model * glm::vec4(0, 1, 0, 1))[1];
}

这里是负责检测是否与地面碰撞,如果发生碰撞就进行位置修正和提供反作用力的代码。先把粒子位置和速度用布料的 model 从局部空间变到世界空间,用 land->collide(pos)(点与地面参考点的有符号高度 dot(pos - point, normal) 是否 ≤ 0)判断是否穿透;若穿透则把世界坐标的 y 抬到 land->top() + 0.01 并变回局部写回 particle->position 防止继续下沉,同时在世界空间把速度拆成法向分量和切向分量,法向乘 -0.1 做反弹、切向按与法向速度相关的比例做摩擦衰减,再把合成后的速度用 inverse(model) 变回局部写回 particle->velocity,从而在布料局部坐标系下完成"防穿透 + 反弹 + 摩擦"的碰撞响应。

注意:地面修正的逻辑是:先把粒子变到世界坐标,判断是否穿透;穿透则把位置抬到地面之上,并对"经历位置修正"的粒子做速度响应------法向分量按恢复系数反弹,切向分量按摩擦系数衰减,二者都是直接改速度,而不是施加力。

cpp 复制代码
//更新法向量为了渲染起来好看,看起来平滑柔软而不是一块一块的
void Cloth::updateNormal() {
	// 更新每个粒子的法向量
	for (auto particle : particles) {
		particle->normal = glm::vec3(0);
	}

	// 更新三角形的法向量
	for (auto triangle : triangles) {
		triangle->updateNormal();
	}

	// 归一化粒子的法向量
	for (auto particle : particles) {
		particle->normal = glm::normalize(particle->normal);
	}
}

这个部分是负责更新粒子法线方向的,正如之前所说,为了实现平滑着色。

cpp 复制代码
// 更新三角形的法线
void Triangle::updateNormal() {
    // 计算两个边:从 p1 到 p2 和从 p1 到 p3
    glm::vec3 side1 = p2->position - p1->position;
    glm::vec3 side2 = p3->position - p1->position;
    // 通过叉乘计算法线向量,并归一化
    glm::vec3 normal = glm::normalize(glm::cross(side1, side2));

    // 将计算得到的法线添加到每个粒子的法线中
    p1->normal += normal;
    p2->normal += normal;
    p3->normal += normal;
}

上文中调用的更新三角形的法线的函数,逻辑是先更新三角形法线再更新粒子法线。

cpp 复制代码
void Cloth::updateAcceleration() {
	// 更新每个粒子的加速度
	for (auto particle : particles) {
		particle->clearAcceleration();  // 清除之前的加速度
		particle->applyAcceleration(glm::inverse(model) * glm::vec4(0, -9.8, 0, 0));  // 应用重力加速度
	}

	// 更新弹簧阻尼器的加速度
	for (auto springDamper : springDampers) {
		springDamper->updateAcceleration();
	}

	// 应用风力
	for (auto triangle : triangles) {
		triangle->wind(localWind);
	}
}

然后我们就来更新每个粒子的加速度来,显然我们需要先清零粒子的加速度,不然粒子之前受的力的效果始终不会消失,然后我们优先更新粒子的加速度,然后更新弹簧的加速度,最后应用风力的效果。

cpp 复制代码
// 更新弹簧阻尼器的加速度
void SpringDamper::updateAcceleration() {
    // 计算当前粒子之间的距离
    float currLength = glm::length(p2->position - p1->position);
    // 计算偏移量:当前长度与静止长度之差
    float deltaX = currLength - restLength;
    glm::vec3 direction;

    // 如果当前长度为 0,设定弹簧方向为 (0, 1, 0),避免除零错误
    if (currLength == 0) {
        direction = glm::vec3(0, 1, 0);
    }
    else {
        // 计算单位向量,表示弹簧的方向
        direction = glm::normalize(p2->position - p1->position);
    }

    // 如果当前长度大于静止长度的 1.2 倍,则进行过度伸展的处理
    if (currLength > 1.2 * restLength) {
        // 计算弹簧中点
        glm::vec3 center = (p2->position + p1->position) / 2.0f;
        // 如果 p1 没有被固定,则将 p1 位置更新为弹簧伸长的适当位置
        if (!p1->fixed) {
            p1->position = center - 0.6f * restLength * direction;
        }
        // 如果 p2 没有被固定,则将 p2 位置更新为弹簧伸长的适当位置
        if (!p2->fixed) {
            p2->position = center + 0.6f * restLength * direction;
        }
    }
    // 如果当前长度小于静止长度的 0.5 倍,则进行过度压缩的处理
    else if (currLength < 0.5 * restLength) {
        // 计算弹簧中点
        glm::vec3 center = (p2->position + p1->position) / 2.0f;
        // 如果 p1 没有被固定,则将 p1 位置更新为弹簧压缩的适当位置
        if (!p1->fixed) {
            p1->position = center - restLength * direction / 4.0f;
        }
        // 如果 p2 没有被固定,则将 p2 位置更新为弹簧压缩的适当位置
        if (!p2->fixed) {
            p2->position = center + restLength * direction / 4.0f;
        }
    }

    // 计算两个粒子之间的相对速度在弹簧方向上的分量
    glm::vec3 vClose = glm::dot(p2->velocity - p1->velocity, direction) * direction;
    // 计算弹簧力和阻尼力
    glm::vec3 force = ks * deltaX * direction + kd * vClose;

    // 将计算得到的力施加到两个粒子上,弹簧力是相反的作用力
    p1->applyForce(force);
    p2->applyForce(-force);
}

先根据两端粒子的位置算出弹簧当前的长度、形变量和方向,若拉得太长或压得太短,就直接在位置层面把两点拉回到"合理距离"附近(硬约束防发散);然后按胡克定律 + 阻尼力算出沿弹簧方向的力,分别以相反方向施加到两个粒子上,通过 applyForce 写入它们的加速度,驱动后续的速度/位置更新。

cpp 复制代码
// 计算风力对三角形的作用
void Triangle::wind(glm::vec3 vWind) {
    // 计算三角形的两个边:从 p1 到 p2 和从 p1 到 p3
    glm::vec3 side1 = p2->position - p1->position;
    glm::vec3 side2 = p3->position - p1->position;
    // 计算三角形的面积,使用叉乘来得到平行四边形的面积,再除以 2 得到三角形面积
    float area = glm::length(glm::cross(side1, side2)) / 2.0f;

    // 计算三角形的法线
    glm::vec3 normal;
    if (area == 0) {
        normal = glm::vec3(0, 1, 0);  // 如果面积为 0(可能是共线的情况),使用默认法线
    }
    else {
        normal = glm::normalize(glm::cross(side1, side2));  // 归一化叉乘结果得到法线
    }

    // 计算三角形的平均速度
    glm::vec3 vTriangle = (p1->velocity + p2->velocity + p3->velocity) / 3.0f;
    // 计算相对风速(风速减去三角形的平均速度)
    glm::vec3 vClose = vTriangle - vWind;

    // 计算与风速方向垂直的面积分量
    float crossArea;
    if (glm::length(vClose) == 0) {
        crossArea = 0;  // 如果风速为 0,则没有风力作用
    }
    else {
        // 使用风速与法线的点积来计算垂直于风速的面积分量
        crossArea = area * glm::dot(vClose, normal) / glm::length(vClose);
    }

    // 计算风力作用于三角形的力,公式为:拖曳力 = -0.5 * 流体密度 * 拖曳系数 * 风速平方 * 面积 * 法线
    glm::vec3 force = -fluidDensity * dragCo * glm::dot(vClose, vClose) * crossArea * normal / 2.0f;

    // 将风力分配到每个粒子上,假设风力均匀作用于三个粒子
    glm::vec3 forceOnParticle = force / 3.0f;

    // 对每个粒子施加计算得到的风力
    p1->applyForce(forceOnParticle);
    p2->applyForce(forceOnParticle);
    p3->applyForce(forceOnParticle);
}

然后就是执行风力的效果,和之前的情况不同,我们这里需要计算三角形的面积(两条边叉乘结果除以2),先算三角形面积和法线、再算相对风速和迎风面积,用拖曳公式得到三角形所受风力,然后均分到三个顶点上,通过 applyForce 写成粒子加速度,参与下一帧的积分。

这里牵扯到一个拖曳公式:

这样布料模拟中的一个粒子的一帧中执行的内容就完成了打,但我们还差最后的一步。

cpp 复制代码
// 更新粒子的位置和法向量
	for (int i = 0; i < particles.size(); ++i) {
		positions[i] = particles[i]->position;
	}

	for (int i = 0; i < particles.size(); ++i) {
		normals[i] = particles[i]->normal;
	}

	// 绑定VAO并更新数据
	glBindVertexArray(VAO);

	// 更新顶点位置VBO
	glBindBuffer(GL_ARRAY_BUFFER, VBO_positions);
	glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec3) * positions.size(), positions.data(), GL_STATIC_DRAW);
	glEnableVertexAttribArray(0);
	glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), 0);

	// 更新法向量VBO
	glBindBuffer(GL_ARRAY_BUFFER, VBO_normals);
	glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec3) * normals.size(), normals.data(), GL_STATIC_DRAW);
	glEnableVertexAttribArray(1);
	glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), 0);

我们还需要去更新VAO,顶点位置和法线方向的VBO,保证数据的更新,EBO 不变,只更新顶点数据实现动态网格绘制。

如果有人记不住VAO,VBO和EBO这些概念的话:

这个项目中两个VBO分别存顶点的位置和法线方向,一个EBO存顶点索引,一个VAO记录如何使用VBO和EBO。

最后展示一下效果:

相关推荐
大江东去浪淘尽千古风流人物2 小时前
【claw】 OpenClaw 的架构设计探索
深度学习·算法·3d·机器人·slam
闻缺陷则喜何志丹2 小时前
【字典树 回溯】P7210 [COCI 2020/2021 #3] Vlak|普及+
c++·算法·字典树·回溯·洛谷
夏玉林的学习之路2 小时前
委托构造和using关键字
开发语言·c++·算法
small-pudding2 小时前
深入理解PDF:蒙特卡洛光线追踪中的概率密度函数
算法·pdf·图形渲染
We་ct2 小时前
LeetCode 46. 全排列:深度解析+代码拆解
前端·数据结构·算法·leetcode·typescript·深度优先·回溯
逆境不可逃2 小时前
LeetCode 热题 100 之 763.划分字母区间
算法·leetcode·职场和发展
MicroTech20252 小时前
微算法科技(NASDAQ:MLGO)量子PBFT改进技术:重构联盟链共识的效率与安全
科技·算法·重构
程序员小明儿2 小时前
量子计算探秘:从零开始的量子编程与算法之旅 · 第二篇
算法·量子计算
kronos.荒2 小时前
LRUCache缓存实现
算法·缓存·哈希算法