OpenGL渲染模型 || 2. 3D模型的读取与保存

前言

最近项目中需要使用到OpenGL对3D模型进行渲染。
已有数据为:

  • 带纹理的3D模型
  • 模型上的关键点。

需要实现的功能:

  • 读取和保存 带纹理的3D模型、读取模型的关键点
  • 对模型进行渲染,保存设定角度的渲染图片、以及关键点在相同角度的2D坐标
  • 在渲染图片中模型上任意一个2D点,反向计算到该点空间的3D位置

开发环境:

  • VS2015、三方库 OpenGL、OpenCV。

OpenGL渲染模型 || 1. OpenCV、OpenGL的环境配置
OpenGL渲染模型 || 2. 3D模型的读取与保存
OpenGL渲染模型 || 3. opengl 将模型成渲染图片

1 常用格式简介

经常使用的3D模型的格式:obj、stl、ply。本篇博客介绍前两种的格式的读取。

  • 模型文件中的元素:顶点 v、纹理坐标 vt、法向量 vn、面片 f。
  • obj中记录的是顶点的法向量。stl中记录的是三角面片的法向量。无论哪种法向量,都可以通过顶点+面片的信息计算出来

1.1 OBJ 模型

对于obj的文件,内部数据保存格式为:

v -3.727097 2.102915 -1.141425
v -3.545685 1.435699 -1.619487

vt 0.131240 0.785100
vt 0.133460 0.788660

vn 0.599266 0.516880 -0.611323
vn 0.599266 0.516880 -0.611323

f 34440/33467/42595 42314/33468/42596 16284/33469/42597
f 30281/33470/42598 16288/33471/42599 23351/33472/42600

  • v: 表示顶点,即组成图的点,如立方体有8个顶点,每个顶点有x,y,z三个值。
  • vt: 纹理坐标,其值为u, v。纹理应该是指某个面的粗糙程度(我暂且没看出这个变化),那纹理为什么有坐标呢?想象一块可以自由拉伸的布,坐标表示这个布的一角要放在这个位置,这就是纹理坐标。我们知道一个面最少由三个顶点才能构成,如果此时将一块布拉伸到这三个顶点上,则这块布会变成一个三角形。
  • vn:顶点法向量,其值为x,y,z,你可能好奇为什么顶点有法向量,其实这个法向量是表示顶点的朝向。如果有三个顶点组成一个面,面是有两个朝向,向里或向外,所以可以通过顶点的朝向来确定面的朝向。而且这三个顶点的法向量是一样的。
  • f:表示一个面,由三个v/vt/vn的索引形式组成。比如obj文件中f 5/15/7 4/14/6 6/16/8 ,表示由第5、第4、第6这三个顶点组成了一个三角平面,平面的纹理由第15、第14、第16这三个纹理坐标形成,这个平面的朝向是第7、第6、第8这三个顶点的法向量求平均值

1.2 STL 模型

STL文件有两种格式,ASCII STL、Binary STL。
ASCII STL:每一个face由7行数据组成,outer loop后面三个顶点的顺序沿法线矢量逆时针排序,格式如下:

solid name       // 文件名及文件路径
facet normal ni nj nk   // 三角形法向量的三个分量
  outer loop
   vertex v1x v1y v1z // 第一个顶点坐标
   vertex v2x v2y v2z // 第二个顶点坐标
   vertex v3x v3y v3z // 第三个顶点坐标
  endloop
endfacet         // 完成一个三角形的定义
endsolid name       // 整个文件结束

Binary STL:

  • 起始有80个字节文件头用于存储文件名,
  • 紧接4个字节表示三角面片的数量,
  • 每个三角面片占用固定的50个字节:法向矢量–3个4字节浮点数、第一个顶点–3个4字节浮点数、第二个顶点–3个4字节浮点数、第三个顶点–3个4字节浮点数、接着2个字节描述三角形基本属性

UINT8[80] – Header         // 文件头
UINT32 – Number of triangles    // 三角形数量
// foreach triangle
REAL32[3] – Normal vector     // 法线矢量
REAL32[3] – Vertex 1        // 第一个顶点坐标
REAL32[3] – Vertex 2        // 第二个顶点坐标
REAL32[3] – Vertex 3        // 第三个顶点坐标
UINT16 – Attribute byte count     // 文件属性
end

2 代码实现


2.1 ModelMesh.h

该脚本定义了所需的数据结构,以及实现模型的读取与保存的类Meshcnn

#pragma once

#include<vector>
#include<string>
#include<opencv2/opencv.hpp>
#include<fstream>
#include<sstream>
#include<iostream>

using namespace std;

/* 数据结构----顶点、法向、三角形 */
typedef cv::Point3f Vertex;
typedef cv::Point3f Normal;

struct Tri
{
	int v1;
	int v2;
	int v3;
	Tri() :v1(0.0), v2(0.0),v3(0.0){}
	Tri(int x, int y, int z)
	{
		v1 = x;
		v2 = y;
		v3 = z;
	}
};

struct TextCoodIndex
{
	int v1;
	int v2;
	int v3;
	TextCoodIndex(int x, int y, int z)
	{
		v1 = x;
		v2 = y;
		v3 = z;
	}
};

struct TextureCood
{
	float tx;
	float ty;
	TextureCood(float x, float y)
	{
		tx = x;
		ty = y;
	}
};

// 在STL模型读取后,该结构体在顶点的去重复中使用
struct VtxIdxSortItem {
	int i;
	Vertex value;
};


下面是 class ModelMesh 的定义,
主要实现功能是:obj、stl模型的保存和读取;关键点的保存和读取;模型点、面的法向的计算

class ModelMesh
{
public:
	ModelMesh();
	~ModelMesh();

	/* 读取关键点 */
	bool readJoint(string name);

	/* 解析obj模型 */
	bool readObj(std::string fileName);
	void saveObj(std::string fileName);

	/* 读取stl模型 */
	bool readSTL(std::string cfilename);
	bool saveSTL(const char *filename, std::vector<Vertex>&vtx, std::vector<Tri> &tris, int format);


public:
    /* 计算面法向 */
	void getFaceNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm);
    /* 计算点法向 */
	void getVertexNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm);
	Normal cross(const Vertex &a, const Vertex &b, const Vertex &c, bool isnormal);

    /* 去除重复的顶点 */
    void UnifyDuplicatedVertices(vector<Vertex> &vtx, vector<Tri> &tris);
	bool VtxIdxSortItem_LessSecond(const VtxIdxSortItem & m1, const VtxIdxSortItem & m2);

	std::vector<Vertex> vtx;          /// 模型转换过后的大小和方向
	std::vector<Tri> tris;            /// 三角形
	std::vector<Normal> vtxNrm;       /// 顶点法向
	std::vector<Normal> faceNrm;      /// 面法向

	std::vector<TextureCood> texcodNrm;
	std::vector<TextCoodIndex> textures;

	cv::Point3f center;               /// 网格中心
	float radius;                     /// 网格半径

	vector<cv::Point3f> keypoint;
	string modelname;
	string textureFile;

};

2.2 ModelMesh.cpp

#include"ModelMesh.h"

#define LINE_IS(text) begins_with(buf, text)
// 在obj 模型的读取时,会用到。

ModelMesh::ModelMesh()
{
}

ModelMesh::~ModelMesh()
{
}

bool begins_with(const char *s1, const char *s2)
{
	using namespace ::std;
	return !_strnicmp(s1, s2, strlen(s2));
}


2.2.1 法向量的计算

obj模型中,不一定会提供顶点法向量;stl 只提供了面片的法向量。但无论是面片的法向量,还是顶点的法向量,都可以通过顶点和面片信息计算出来。

  • 面的法向量:只需获取三角面片三个顶点;由三个顶点可以组成面上的两个向量;两个向量的叉乘,就是面的法向量。
  • 顶点的法向量:该顶点所在的多个面的法向量之和。
// 计算面的法向量
void ModelMesh::getFaceNormals(const vector<Vertex> &vtx, const vector<Tri> &tris, vector<Normal> &nrm)
{
	for (int i = 0; i < tris.size(); i++) {
		Normal norm = cross(vtx[tris[i].v1], vtx[tris[i].v2], vtx[tris[i].v3], 1);
		nrm.push_back(norm);
	}
}

// 计算顶点的法向量
void ModelMesh::getVertexNormals(const vector<Vertex> &vtx, const vector<Tri> &tris,
	vector<Normal> &nrm)
{
	nrm = vector<Normal>(vtx.size(), Normal());

	for (int i = 0; i < tris.size(); i++) {
		Normal norm = cross(vtx[tris[i].v1], vtx[tris[i].v2], vtx[tris[i].v3], 0);
		nrm[tris[i].v1] += norm;
		nrm[tris[i].v2] += norm;
		nrm[tris[i].v3] += norm;
	}

	// unitary
	for (int n = 0; n < nrm.size(); n++) {
		float s = sqrt(nrm[n].dot(nrm[n]));
		if (s != 0) {
			nrm[n] = nrm[n] / s;
		}
	}
}

// 已知三个顶点,计算三个点构成的平面的法向量
Normal ModelMesh::cross(const Vertex &a, const Vertex &b, const Vertex &c, bool isnormal)
{
	Vertex Vac = a - c;
	Vertex Vbc = b - c;
	Normal faceN = Vac.cross(Vbc);
	float s = sqrt(faceN.dot(faceN));

	if (!isnormal) return faceN;
	else
	{
		if (s != 0) return faceN / s;
		else return Normal();
	}
}


2.2.2 Obj模型的读取与保存

这里读取的obj是具有纹理信息的,这样的模型渲染出来才是彩色的效果。如果没有纹理,稍加修改代码也可跑通程序,完成渲染。
流程:

  • 获取obj模型的信息: v、vt、vn、f
  • 计算模型中心位置已经半径,为后面的相机参数做准备
bool ModelMesh::readObj(std::string fileName)
{
	FILE *f = fopen(fileName.c_str(), "rb");
	if (f == NULL)
	{
		printf(" %s ", " can not open the file ");
		return false;
	}

	vector<int> thisface;
	float cx = 0.0f, cy = 0.0f, cz = 0.0f;

	while (1)
	{
		//cout << "====4" << endl;
		if (feof(f))
		{
			printf("=============");
			break;
		}
		char buf[1024];
		fgets(buf, 1024, f);

		if (LINE_IS("v ") || LINE_IS("v\t")) {
			//cout << "====5" << endl;
			float x, y, z;
			float a, b, c;
			if (sscanf(buf + 1, "%f %f %f", &x, &y, &z) != 3) {
				printf("v error-------------");
				return false;
			}	
			cx += x;
			cy += y;
			cz += z;
			vtx.push_back(Vertex(x, y, z));
		}
		else if (LINE_IS("vn ") || LINE_IS("vn\t")) {
			//cout << "====6" << endl;
			float x, y, z;
			if (sscanf(buf + 2, "%f %f %f", &x, &y, &z) != 3) {
				printf("vn error-------------");
				return false;
			}
			vtxNrm.push_back(Normal(x, y, z));
		}
		else if (LINE_IS("vt ") || LINE_IS("vt\t"))
		{
			//cout << "====7" << endl;
			float x, y;
			if (sscanf(buf + 2, "%f %f", &x, &y) != 2) {
				printf("vt error-------------");
				return false;
			}
			texcodNrm.push_back(TextureCood(x, y));

		}
		else if (LINE_IS("f ") || LINE_IS("f\t")) {
			//cout << "====8" << endl;
			int v1, vn1, vt1;
			int v2, vn2, vt2;
			int v3, vn3, vt3;
			if (sscanf(buf + 1, "%d/%d/%d %d/%d/%d %d/%d/%d", &v1, &vt1, &vn1, &v2, &vt2, &vn2, &v3, &vt3, &vn3) != 9) {
				printf("f error-------------");
				return false;
			}

			tris.push_back(Tri(v1 - 1, v2 - 1, v3 - 1));
			faceNrm.push_back(Normal(vn1 - 1, vn2 - 1, vn3 - 1));
			textures.push_back(TextCoodIndex(vt1 - 1, vt2 - 1, vt3 - 1));
		}
		else if (LINE_IS("mtllib "))
		{
			char str[64];
			if (sscanf(buf + 6, "%s", str) == 0) {
				printf("mtllib error-------------");
				return false;
			}
			//cout << "====9" << endl;
			printf("%s", str);
			int idx = fileName.find_last_of('/');// fileName.lastIndexOf('/');
			string fn = fileName.substr(0, idx + 1) + string(str);
			cout << str << endl;
			cout << idx << endl;
			cout << fn << endl;
			//cout << "====10" << endl;
			ifstream ifs(fn);
			if (!ifs.is_open())
			{
				break;
			}

			while (!ifs.eof())
			{
				string line;
				std::getline(ifs, line);

				if (!line.empty())
				{
					std::stringstream ss(line);
					string s1, s2;
					ss >> s1 >> s2;
					int index = s1.find("map_Kd");
					if (index == 0)
					{
						cout << endl << s2 << endl;
						textureFile = fileName.substr(0, idx + 1) + s2;
					}
				}
			}
			ifs.close();
		}
	}

	// 计算中心位置
	center.x = cx / vtx.size();
	center.y = cy / vtx.size();
	center.z = cz / vtx.size();

	// 计算半径
	radius = 0;
	for (int i = 0; i<vtx.size(); i++)
	{
		//cout << "点数: " << vtx.size() << endl;
		float lens;
		lens = sqrt((vtx[i] - center).dot((vtx[i] - center)));
		if (lens>radius)
		{
			radius = lens;
		}
	}
	cout << center << endl;
	cout << radius << endl;
	return true;
}


保存模型时,只需按照obj的格式保存即可。这里保存obj模型,是为了代码运行过程中,保存查看模型的方向。

void ModelMesh::saveObj(std::string path)
{

	std::ofstream fout(path.c_str());
	double scale = 1.0f;
	fout << "mtllib test.mtl" << endl;;

	for (int i = 0; i<vtx.size(); i++)
	{
		fout << "v " << scale*vtx[i].x << " " << scale * vtx[i].y << " " << scale*vtx[i].z << std::endl;
	}
	for (int i = 0; i<texcodNrm.size(); i++)
	{
		fout << "vt " << texcodNrm[i].tx << " " << texcodNrm[i].ty << std::endl;
	}
	for (int i = 0; i<vtxNrm.size(); i++)
	{
		fout << "vn " << vtxNrm[i].x << " " << vtxNrm[i].y << " " << vtxNrm[i].z << std::endl;
	}

	for (int i = 0; i<tris.size(); i++)
	{
		fout << "f " << tris[i].v1 + 1 << "/" << textures[i].v1 + 1 << "/" << faceNrm[i].x + 1 << " "
			<< tris[i].v2 + 1 << "/" << textures[i].v2 + 1 << "/" << faceNrm[i].y + 1 << " "
			<< tris[i].v3 + 1 << "/" << textures[i].v3 + 1 << "/" << faceNrm[i].z + 1
			         << std::endl;
	}
	fout.close();
}


2.2.3 STL模型的读取与保存

根据前面陈述的STL的格式,即可正确的读取模型。STL的面片法向量的获取,可以直接从模型中读取,也可以自行计算。

bool ModelMesh::readSTL(std::string fileName)
{
	// 这里只记录了Binary STL的读取代码
	ifstream ifs(fileName.c_str(), ios::binary);
	if (!ifs)
	{
		ifs.close();
		cout << "无法正确读取文件,请检查文件路径是否正确" << endl;
		return false;
	}

	vtx.clear();
	tris.clear();
	
	int intSize = sizeof(int);
	int floatSize = sizeof(float);
	ifs.ignore(80);

	// 面的个数
	int num_tris;
	ifs.read((char*)(&num_tris), intSize);

	float tn0, tn1, tn2;
	float v0, v1, v2;
	float cx = 0.0, cy = 0.0, cz = 0.0;


	for (int i = 0; i < num_tris; i++)
	{
		ifs.read((char*)(&tn0), floatSize);
		ifs.read((char*)(&tn1), floatSize);
		ifs.read((char*)(&tn2), floatSize);
		faceNrm.push_back(Normal(tn0, tn1, tn2)); 
		/*如果模型进行了坐标交换,法向尽量不用原本提供的,
		方向上容易出错,用后面计算出来的法向*/

		ifs.read((char*)(&v0), floatSize);
		ifs.read((char*)(&v1), floatSize);
		ifs.read((char*)(&v2), floatSize);
		vtx.push_back(Vertex(v0, v1, v2));
		cx += v0; cy += v1; cz += v2;

		ifs.read((char*)(&v0), floatSize);
		ifs.read((char*)(&v1), floatSize);
		ifs.read((char*)(&v2), floatSize);
		vtx.push_back(Vertex(v0, v1, v2));
		cx += v0; cy += v1; cz += v2;

		ifs.read((char*)(&v0), floatSize);
		ifs.read((char*)(&v1), floatSize);
		ifs.read((char*)(&v2), floatSize);
		vtx.push_back(Vertex(v0, v1, v2));
		cx += v0; cy += v1; cz += v2;	

		Tri tri;
		tri.v1 = i * 3 + 0;
		tri.v2 = i * 3 + 1;
		tri.v3 = i * 3 + 2;
		tris.push_back(tri);

		ifs.ignore(2);
	}
	ifs.close();
	// 计算三角面片的法向;
	if (0)
	{
		faceNrm.clear();
		getFaceNormals(vtx, tris, faceNrm);
	}

	// 计算中心位置
	center.x = cx / (num_tris * 3); 
	center.y = cy / (num_tris * 3); 
	center.z = cz / (num_tris * 3);

	//计算半径
	radius = 0;
	for (int i = 0; i < vtx.size(); i++)
	{
		float lens;
		lens = sqrt((vtx[i] - center).dot(vtx[i] - center));
		if (lens > radius)
		{
			radius = lens;
		}
	}
	return true;
}


STL模型保存时,选择一种方式,按照其格式正确保存即可。

bool ModelMesh::saveSTL(const char *filename, std::vector<Vertex>&vtx, std::vector<Tri> &tris, int format)
{
	vector<Normal> nrm;
	getFaceNormals(vtx, tris, nrm);

	if (format == 0) {
		ofstream fs(filename, ios::binary);
		if (!fs) { fs.close(); return false; }

		int i;
		int intSize = sizeof(int);
		int floatSize = sizeof(float);
		// 文件头
		char* fileHead = "ROC";
		fs.write(fileHead, sizeof(char) * 3);
		// 附加信息
		char fileInfo[77];
		for (i = 0; i<77; i++) fileInfo[i] = ' ';
		fs.write(fileInfo, sizeof(char) * 77);
		// 面的个数
		int num_tris = int(tris.size());
		fs.write((char*)(&num_tris), intSize);
		// 点列表,面列表
		char a[2];
		streamsize a_size = sizeof(char) * 2;
		for (i = 0; i<num_tris; i++)
		{
			int PIndex0 = tris[i].v1;
			int PIndex1 = tris[i].v2;
			int PIndex2 = tris[i].v3;

			Vertex P0 = vtx[PIndex0];
			Vertex P1 = vtx[PIndex1];
			Vertex P2 = vtx[PIndex2];
			Normal N0 = vtx[PIndex0];
			Normal N1 = vtx[PIndex1];
			Normal N2 = vtx[PIndex2];

			Normal tn = nrm[i];
			fs.write((char*)(&(tn.x)), floatSize);
			fs.write((char*)(&(tn.y)), floatSize);
			fs.write((char*)(&(tn.z)), floatSize);

			fs.write((char*)(&(P0.x)), floatSize);
			fs.write((char*)(&(P0.y)), floatSize);
			fs.write((char*)(&(P0.z)), floatSize);
			fs.write((char*)(&(P1.x)), floatSize);
			fs.write((char*)(&(P1.y)), floatSize);
			fs.write((char*)(&(P1.z)), floatSize);
			fs.write((char*)(&(P2.x)), floatSize);
			fs.write((char*)(&(P2.y)), floatSize);
			fs.write((char*)(&(P2.z)), floatSize);
			fs.write(a, a_size);
		}
		fs.close();
		return true;
	}
	if (format == 1) {
		int n, i;
		ofstream fs(filename);
		if (!fs) {
			fs.close();
			return false;
		}
		fs << "solid ods\n";
		n = int(tris.size());
		Vertex tn;
		for (i = 0; i<n; i++)
		{
			int PIndex0 = tris[i].v1;
			int PIndex1 = tris[i].v2;
			int PIndex2 = tris[i].v3;
			Normal P0 = vtx[PIndex0];
			Normal P1 = vtx[PIndex1];
			Normal P2 = vtx[PIndex2];
			Normal tn = nrm[i];
			fs << "facet normal " << tn.x << " " << tn.y << " " << tn.z << "\nouter loop\n"
				<< "vertex " << P0.x << " " << P0.y << " " << P0.z << "\n"
				<< "vertex " << P1.x << " " << P1.y << " " << P1.z << "\n"
				<< "vertex " << P2.x << " " << P2.y << " " << P2.z << "\n"
				<< "endloop\nendfacet\n";
		}
		fs << "endsolid";
		fs.close();
		return true;
	}
	return false;
};


2.2.4 STL读取后,顶点的去重复

STL模型的存储信息,是以面片的数量为单位存储的。这样读取出来的顶点,必然存在较多的重复性。为了后续的算法的高效,需要对顶点数量去重复。


bool VtxIdxSortItem_LessSecond(const VtxIdxSortItem & m1, const VtxIdxSortItem & m2) {
	bool b = false;

	if (m1.value.x > m2.value.x)
		b = true;
	else if (m1.value.x == m2.value.x)
		if (m1.value.y > m2.value.y)
			b = true;
		else if (m1.value.y == m2.value.y)
			if (m1.value.z > m2.value.z)
				b = true;
			else
				b = false;

	return b;
};
// 排序分块计算
void UnifyDuplicatedVertices(vector<Vertex> &vtx, vector<Tri> &tris)
{
	if (vtx.size() == 0)
		return;

	vector<Vertex> vtxNew;
	vector<int> vtxIdxMap(vtx.size(), -1);

	vector<int> vtxIdx;
	for (int i = 0; i<int(vtx.size()); i++) {
		vtxIdx.push_back(i);
	}

	// 去冗余运算
	vector<VtxIdxSortItem> vtxIdxSortItemList;
	VtxIdxSortItem item;
	for (int i = 0; i<int(vtx.size()); i++) {
		item.i = vtxIdx[i];
		item.value = vtx[i];
		vtxIdxSortItemList.push_back(item);
	}

	sort(vtxIdxSortItemList.begin(), vtxIdxSortItemList.end(), VtxIdxSortItem_LessSecond);

	vtxNew.push_back(vtxIdxSortItemList[0].value);
	vtxIdxMap[vtxIdxSortItemList[0].i] = int(vtxNew.size() - 1);
	for (int i = 1; i<int(vtxIdxSortItemList.size()); i++) {
		if (vtxNew[vtxNew.size() - 1] != vtxIdxSortItemList[i].value) {
			vtxNew.push_back(vtxIdxSortItemList[i].value);
		}

		vtxIdxMap[vtxIdxSortItemList[i].i] = int(vtxNew.size() - 1);
	}

	vtx = vtxNew;
	// 将更新点列表和更新三角形点序号
	for (int i = 0; i<int(tris.size()); i++) {
		tris[i].v1 = vtxIdxMap[tris[i].v1];
		tris[i].v2 = vtxIdxMap[tris[i].v2];
		tris[i].v3 = vtxIdxMap[tris[i].v3];
	}

};


2.2.5 读取关键点

3D关键点的存储在txt文本中,保存的格式为:每行代表一个点,每行内容为 x,y,z。读取的函数也是非常简单

bool ModelMesh::readJoint(string name)
{
	std::ifstream fout1(name.c_str());
	if (!fout1.is_open())
	{
		printf("无法正确读取文件");
		return false;
	}
	while (!fout1.eof())
	{
		string line;
		getline(fout1, line);
		std::stringstream data(line);

		float a1, b1, c1;
		data >> a1 >> b1 >> c1;
		keypoint.push_back(cv::Point3f(a1, b1, c1));
	}
	return true;
}

  • 6
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 9
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 9
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值