OpenGL贴图保持原比例的一种方法

本文详细介绍了在OpenGL中如何通过变换顶点坐标来保持纹理贴图的比例,即使原始顶点坐标与图片比例不一致。首先计算视口的短边与长边比例,然后根据图片宽高比例调整顶点缩放,确保在长边填充视口的同时,短边按比例缩放,从而保持图片原有的纵横比。通过备份和恢复物体变换矩阵,实现了临时变换,使得无论容器比例如何,都能正确显示图片比例。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

 例如我创建了一个全屏的纹理顶点,但图片不一定是刚好符合屏幕比例的,这样纹理贴图一贴上去,一拉伸就不符合图片比例了,会比较难看,那么对顶点做怎样的临时变换可以维持渲染的图片保持比例呢?

      我的基本环境设置如下:

glViewport(0, 0, w, h);//设置视口
float ratio = (float) h / w;    
frustumM(mProjMatrix, 0, -1, 1, -ratio, ratio, 1, 50);//设置投影矩阵

投影矩阵设置方法:

void frustumM(float *m, int offset, float left, float right, float bottom, float top, float near, float far)
{
    const float r_width = 1.0f / (right - left);
    const float r_height = 1.0f / (top - bottom);
    const float r_depth = 1.0f / (near - far);
    const float x = 2.0f * (near * r_width);
    const float y = 2.0f * (near * r_height);
    const float A = 2.0f * ((right + left) * r_width);
    const float B = (top + bottom) * r_height;
    const float C = (far + near) * r_depth;
    const float D = 2.0f * (far * near * r_depth);
    m[offset + 0] = x;
    m[offset + 5] = y;
    m[offset + 8] = A;
    m[offset + 9] = B;
    m[offset + 10] = C;
    m[offset + 14] = D;
    m[offset + 11] = -1.0f;
    m[offset + 1] = 0.0f;
    m[offset + 2] = 0.0f;
    m[offset + 3] = 0.0f;
    m[offset + 4] = 0.0f;
    m[offset + 6] = 0.0f;
    m[offset + 7] = 0.0f;
    m[offset + 12] = 0.0f;
    m[offset + 13] = 0.0f;
    m[offset + 15] = 0.0f;
}

使用的顶点配置如下:

createRender(-1, -ratio, 0, 2, ratio * 2, w, h);
void RenderProgramImage::createRender(float x, float y, float z, float w, float h, int windowW,int windowH) {
    //...
    mWindowW = windowW;
    mWindowH = windowH;
    initObjMatrix(); //使物体矩阵初始化为单位矩阵,否则接下来的矩阵操作因为都是乘以0而无效
    float vertxData[] = {
            x + w, y, z,
            x, y, z,
            x + w, y + h, z,
            x, y + h, z,
    };
    memcpy(mVertxData, vertxData, sizeof(vertxData));
   //...
}

就是典型的跨过全屏的顶点坐标。    

 直接贴图效果是这样的:

 

 

我们知道OpenGL使用的是归一化的坐标系:

所以我们通过以下步骤变换顶点使得其符合图片原来的比例:

0、首先我们假定视口是个正方形,大小一比一。备份现在的顶点变换矩阵。

1、通过图片宽高分别除以视口宽高,得到两个比值。

2、比值较大的边所对应的轴的顶点坐标不需要变换,另外比值较小的边所对应的缩放值乘以 (较小边/较大边)的比值即可在较长边充满顶点的同时,另外一轴的顶点缩放到符合图片比例的长度,具体矩阵操作如下:

3、上面一步的比例是假定视口长宽为1:1的计算,但实际我们还要乘以视口设置的比例,才能最终在投影到视口时比例依然符合我们的目标。

4、绘制完成后还原变换矩阵。

变换过程如图:

 

 实现代码如下:

//保留物体缩放现场
    float objMatrixClone[16];
    memcpy(objMatrixClone, mObjectMatrix, sizeof(objMatrixClone));
float ratio =
                mWindowW > mWindowH ? ((float) mWindowH / (float) mWindowW) : ((float) mWindowW /
                                                                               (float) mWindowH); //计算当前视口的短边/长边比例,从而得知X轴和Y轴的-1~1的归一化长度之间的实际长度的比例
        //确定图片哪一边更能覆盖对应轴的视口长度,哪一边就让其充满空间,另一边则按OpenGL视口的短边/长边比缩放,此时任意长宽比的图片都会变成矩形,再乘以图片本身的比例转换为图片本身宽高比,即可在纹理渲染时还原图片本身比例
        float widthPercentage = (float) mRenderSrcData.width / (float) mWindowW;
        float heightPercentage = (float) mRenderSrcData.height / (float) mWindowH;
        if (widthPercentage > heightPercentage) { //如果宽占比更多,宽拉伸到尽,高按照视口比例重新调整为统一密度的单位,然后再根据图片高对宽的比例调整物体的高的边的缩放
            scale(1.0, ratio * ((float) mRenderSrcData.height / mRenderSrcData.width), 1.0); //SCALEY为图片高占宽的比例 * 视口比例
        } else {
            scale(ratio * ((float) mRenderSrcData.width / mRenderSrcData.height), 1.0, 1.0);
        }

void Layer::scale(float sx, float sy, float sz) {
    scaleM(mObjectMatrix, 0, sx, sy, sz);
}

void scaleM(float *m, int mOffset, float x, float y, float z)
{
    float sm[16];
    setIdentityM(sm, 0);
    sm[0] = x;
    sm[5] = y;
    sm[10] = z;
    sm[15] = 1;
    float tm[16];
    multiplyMM(tm, 0, m, 0, sm, 0);
    for (int i = 0; i < 16; i++)
    {
        m[i] = tm[i];
    }
}

 绘制时,物体顶点矩阵先乘以摄像机矩阵和投影矩阵确定其在三维空间的真实位置和投影到平面的二维位置和大小。

void Layer::locationTrans(float cameraMatrix[], float projMatrix[], int muMVPMatrixPointer) {
    multiplyMM(mMVPMatrix, 0, cameraMatrix, 0, mObjectMatrix, 0);         //将摄像机矩阵乘以物体矩阵
    multiplyMM(mMVPMatrix, 0, projMatrix, 0, mMVPMatrix, 0);         //将投影矩阵乘以上一步的结果矩阵
    glUniformMatrix4fv(muMVPMatrixPointer, 1, false, mMVPMatrix);        //将最终变换关系传入渲染管线
}

最后传到GLSL脚本种的uMVPMatrix矩阵,再把上面计算的值乘以该变换矩阵:

#version 300 es\n
            uniform mat4 uMVPMatrix; //旋转平移缩放 总变换矩阵。物体矩阵乘以它即可产生变换
            in vec3 objectPosition; //物体位置向量,参与运算但不输出给片源

            in vec4 objectColor; //物理颜色向量
            in vec2 vTexCoord; //纹理内坐标
            out vec4 fragObjectColor;//输出处理后的颜色值给片元程序
            out vec2 fragVTexCoord;//输出处理后的纹理内坐标给片元程序

            void main() {
                gl_Position = uMVPMatrix * vec4(objectPosition, 1.0); //设置物体位置
                fragVTexCoord = vTexCoord; //默认无任何处理,直接输出物理内采样坐标
                fragObjectColor = objectColor; //默认无任何处理,输出颜色值到片源
            }

便实现了符合图片比例的顶点临时变换。无论顶点初始化时是何种比例都能按照图片原比例贴上。

最后效果如下:

 

 

 

 

 

 可以看到无论容器比例如何,照片比例都可以保持一致。

<think>我们正在讨论的是在three.js中如何根据纹理贴图的宽高动态调整repeat和offset参数。 用户的需求是:根据加载的纹理图片的实际尺寸来动态设置纹理的repeat和offset属性。这通常用于当纹理的宽高比不是1:1时,我们希望保持纹理的正确比例,避免拉伸。 在three.js中,纹理对象(THREE.Texture)有repeat和offset属性。repeat是一个二维向量(Vector2),表示在U和V方向上的重复次数;offset也是一个二维向量,表示纹理的偏移。 然而,纹理图片的宽高比可能与模型表面的宽高比不一致。为了保持纹理的正确比例,我们可能需要根据图片的宽高比来调整repeat值,使得纹理在模型上正确显示而不变形。 常见做法: 1. 加载纹理图片,并获取其宽度和高度。 2. 计算纹理的宽高比(aspect = width/height)。 3. 根据模型表面的宽高比(或者我们期望的显示区域宽高比)和纹理宽高比,来调整repeat值。 但注意:用户的问题中并没有指定模型表面的宽高比。所以这里可能需要分两种情况: a) 如果用户希望纹理按照比例重复铺满整个模型(比如一个平面),那么我们需要根据模型尺寸和纹理尺寸来计算repeat。 b) 或者,用户只是希望纹理在保持比例的情况下显示一次(不重复),那么我们可以通过设置repeat为(1,1)并调整offset来居中,但这样可能会有空白。或者通过调整模型的UV。 不过,用户的问题描述是“根据纹理贴图的宽高动态调整repeat和offset”,所以我们主要关注如何根据图片尺寸来设置这两个属性。 实际上,如果我们希望纹理在模型上重复显示,并且保持纹理的比例,那么我们需要知道模型表面的宽高比(比如一个平面几何体的宽高)。然后,我们可以通过以下方式计算: repeatU = 模型宽度 / (纹理宽度 * 期望的纹理显示宽度比例因子) // 这里可能需要根据具体需求调整 repeatV = 模型高度 / (纹理高度 * 期望的纹理显示高度比例因子) 但这可能比较复杂。另一种常见的需求是:让纹理在保持宽高比的情况下覆盖整个平面,类似于CSS中的`background-size: contain`或`cover`。 在three.js中实现类似`contain`的效果(即保持比例,完整显示纹理,可能有空白): - 计算纹理宽高比:aspect = image.width / image.height - 计算模型(平面)宽高比:planeAspect = planeWidth / planeHeight - 比较aspect和planeAspect: 如果aspect > planeAspect,说明纹理相对于平面更宽,那么我们需要在V方向重复更多次(或者U方向重复更少)来保持比例。具体: repeat.set(1, planeAspect / aspect); 如果aspect < planeAspect,说明纹理更高,那么: repeat.set(aspect / planeAspect, 1); 但是注意:这样设置repeat会导致纹理重复的次数小于1(即只显示部分),所以通常我们不会这样设置repeat,而是调整UV映射。不过,用户要求使用repeat和offset。 另一种思路:我们设置repeat为(1,1)然后通过调整offset和repeat来达到保持比例且填满的效果(类似cover)?但这样会裁剪纹理。 由于问题描述不够具体,我们假设用户希望纹理按照比例重复覆盖整个平面,并且不拉伸(即保持比例,重复铺满,类似于墙纸)。那么我们可以这样: repeatU = 平面宽度 / 纹理宽度; repeatV = 平面高度 / 纹理高度; 这样设置后,纹理就会按照始大小在平面上重复。但是,这样设置会导致纹理的重复次数与平面尺寸成正比,即平面越大,重复次数越多。这通常是我们想要的(比如一个10x10的平面,纹理是512x512,那么重复次数就是10/(512/单位长度)?注意单位问题)。 然而,在three.js中,纹理的重复默认是1,即不重复。如果我们设置repeatU和repeatV为上面的计算值,那么纹理就会重复多次。 但是,用户可能希望的是纹理在平面上只显示一次,并且保持比例(不拉伸)。那么我们可以这样: 1. 设置repeat为(1,1) 2. 计算纹理的宽高比和平面宽高比 3. 为了保持纹理比例,我们需要调整UV,但用户要求使用offset和repeat,所以我们可以通过改变repeat和offset来实现类似“视口”的效果。 实际上,如果用户希望纹理保持比例并居中显示(不重复),我们可以通过以下步骤: - 计算纹理宽高比:textureAspect = image.width / image.height - 计算平面宽高比:planeAspect = plane.width / plane.height - 如果textureAspect > planeAspect,说明纹理更宽,那么我们需要将纹理在高度方向上完全显示,宽度方向按比例显示(即高度方向占满,宽度方向两侧超出)。那么: repeat.set(planeAspect / textureAspect, 1) offset.set((1 - planeAspect/textureAspect)/2, 0) - 如果textureAspect < planeAspect,说明纹理更高,那么: repeat.set(1, textureAspect / planeAspect) offset.set(0, (1 - textureAspect/planeAspect)/2) 这样,我们就将纹理按比例缩放并居中显示在平面上了。注意,这样设置后,纹理不会重复,因为repeat的每个分量都小于或等于1,并且我们通过offset调整了位置。 但是,用户的问题中要求“动态调整repeat和offset”,所以我们需要在纹理加载完成后进行计算。 实现步骤: 1. 加载纹理,在加载完成后获取图片的尺寸。 2. 获取需要应用纹理的模型的尺寸(这里假设是一个平面,我们可以通过几何体的bounding box计算宽度和高度)。 3. 根据上述公式计算repeat和offset。 4. 设置纹理的repeat和offset,并设置wrapS和wrapT为THREE.ClampToEdgeWrapping(避免重复)?或者根据需求,如果希望重复则设置为THREE.RepeatWrapping。 注意:如果用户希望的是重复纹理并保持比例,那么上面的第一种方法(直接根据平面尺寸和纹理尺寸计算repeat)更合适。 由于用户的问题没有明确是“contain”还是“cover”还是“repeat”,我们将提供两种常见情况的代码示例: 情况1:保持纹理比例,重复铺满整个平面(类似于墙纸,重复纹理) ```javascript const textureLoader = new THREE.TextureLoader(); textureLoader.load('texture.jpg', function(texture) { // 获取图片尺寸 const image = texture.image; const imageWidth = image.width; const imageHeight = image.height; // 假设我们有一个平面几何体,其宽度和高度已知(例如10和5) const planeWidth = 10; const planeHeight = 5; // 计算重复次数:平面尺寸除以纹理尺寸(注意:这里假设纹理的每个像素对应一个单位?实际上,在three.js中,纹理坐标是0~1,所以我们需要将平面尺寸除以纹理尺寸(但纹理尺寸本身没有单位,我们需要考虑纹理在模型上的缩放) // 实际上,在three.js中,纹理的重复是基于UV坐标的。默认情况下,UV坐标0~1对应整个平面。所以如果我们设置repeat为(planeWidth, planeHeight),那么纹理就会重复planeWidth次在U方向,planeHeight次在V方向。 // 但是,这样设置会导致纹理被拉伸?因为平面UV是0~1,而纹理在0~1内显示一次。如果我们希望一个纹理图像在平面上重复显示,那么我们需要将纹理的重复次数设置为平面尺寸除以纹理的“单位尺寸”。然而,用户可能希望一个纹理图像在平面上重复的次数与平面尺寸成正比,并且保持纹理的比例(即每个纹理图像都是始宽高比)。 // 实际上,如果我们不调整repeat,纹理会被拉伸。为了保持纹理比例,我们需要根据纹理的宽高比来调整重复次数,但这样会导致重复次数在两个方向上不一致?这通常不是我们想要的。 // 因此,更常见的做法是:设置纹理的重复模式为重复(wrapS和wrapT设置为THREE.RepeatWrapping),然后设置repeat为(planeWidth, planeHeight)?但是这样设置,纹理会重复planeWidth次在U方向,planeHeight次在V方向,但每个纹理图像会被拉伸(因为UV坐标0~1对应整个平面,而纹理图像会被拉伸到整个平面)。 // 所以,为了保持纹理图像的比例,我们需要在UV映射时保持纹理的宽高比。这通常需要在创建几何体时调整UV,或者使用一个ShaderMaterial。使用repeat和offset无法直接实现重复且保持比例(因为repeat在两个方向上是独立的,而纹理图像本身有固定比例,重复时每个“格子”里的纹理图像都会被拉伸到该格子)。 // 因此,情况1(重复且保持每个纹理图像的比例)实际上不能仅通过设置repeat和offset来实现。我们需要调整几何体的UV坐标,使得每个“重复单元”内的UV坐标保持纹理的宽高比。 // 所以,我们可能需要重新考虑。 // 另一种思路:如果我们希望纹理在平面上重复,并且每个重复的纹理图像都保持比例,那么我们需要将平面划分成网格,每个网格的宽高比等于纹理的宽高比。但这超出了简单设置repeat和offset的范围。 // 因此,我们回到用户的问题:用户没有明确说明具体需求,所以我们提供情况2(不重复,保持比例并居中)的解决方案。 }); ``` 情况2:纹理不重复,保持比例,并居中显示(contain模式,可能有留白) ```javascript const textureLoader = new THREE.TextureLoader(); textureLoader.load('texture.jpg', function(texture) { const image = texture.image; const imageAspect = image.width / image.height; // 假设平面几何体 const planeWidth = 10; // 平面的实际宽度 const planeHeight = 5; // 平面的实际高度 const planeAspect = planeWidth / planeHeight; // 设置纹理的重复模式为不重复(ClampToEdgeWrapping)或者重复(RepeatWrapping)?这里我们不希望重复,所以设置为ClampToEdge texture.wrapS = THREE.ClampToEdgeWrapping; texture.wrapT = THREE.ClampToEdgeWrapping; if (imageAspect > planeAspect) { // 纹理比平面宽 const scale = planeAspect / imageAspect; texture.repeat.set(scale, 1); texture.offset.set((1 - scale) / 2, 0); } else { const scale = imageAspect / planeAspect; texture.repeat.set(1, scale); texture.offset.set(0, (1 - scale) / 2); } // 然后使用这个纹理创建材质并应用到平面上 const material = new THREE.MeshBasicMaterial({ map: texture }); plane.material = material; // 假设plane是已经创建的平面网格 }); ``` 情况3:纹理不重复,保持比例,并覆盖整个平面(cover模式,可能裁剪) ```javascript // 与contain类似,但是是尽可能填满平面,超出部分裁剪 if (imageAspect > planeAspect) { // 纹理比平面宽,那么高度方向会超出 const scale = planeAspect / imageAspect; texture.repeat.set(1, 1/scale); // 注意:这里repeat大于1,表示放大(只显示部分) texture.offset.set(0, (1 - 1/scale)/2); } else { const scale = imageAspect / planeAspect; texture.repeat.set(1/scale, 1); texture.offset.set((1 - 1/scale)/2, 0); } ``` 但是,注意在cover模式下,repeat的值可能大于1,表示纹理被放大(只显示一部分)。offset用于居中显示。 总结:用户的问题需要根据具体需求来选择模式。我们提供了三种常见模式中的两种(contain和cover)的代码示例,以及重复模式(repeat)的困难点。 因此,回答如下:</think>在 Three.js 中,根据纹理图片的宽高比动态调整 `repeat` 和 `offset` 的关键是**计算纹理的宽高比与目标表面的宽高比**,然后通过数学公式动态设置参数。以下是实现方法和代码示例: --- ### 核心实现步骤 1. **加载纹理并获取图片尺寸** 在纹理加载回调中获取图片的实际宽高: ```javascript const textureLoader = new THREE.TextureLoader(); textureLoader.load('path/to/texture.jpg', (texture) => { const image = texture.image; const imgWidth = image.width; const imgHeight = image.height; const imgAspect = imgWidth / imgHeight; // 纹理宽高比 }); ``` 2. **计算目标表面的宽高比** 假设目标是一个平面几何体(`PlaneGeometry`): ```javascript const planeWidth = 10; // 平面宽度 const planeHeight = 5; // 平面高度 const planeAspect = planeWidth / planeHeight; // 平面宽高比 ``` 3. **动态计算 repeat 和 offset** 根据纹理与平面的宽高比差异,调整参数: ```javascript // 情况1:纹理宽高比 > 平面宽高比(纹理更宽) if (imgAspect > planeAspect) { const scale = planeAspect / imgAspect; texture.repeat.set(1, scale); // 垂直方向缩小 texture.offset.set(0, (1 - scale) / 2); // 垂直居中 } // 情况2:纹理宽高比 < 平面宽高比(纹理更高) else { const scale = imgAspect / planeAspect; texture.repeat.set(scale, 1); // 水平方向缩小 texture.offset.set((1 - scale) / 2, 0); // 水平居中 } ``` 4. **启用纹理重复和偏移** 设置纹理的环绕模式并更新材质: ```javascript texture.wrapS = THREE.RepeatWrapping; // 允许水平重复 texture.wrapT = THREE.RepeatWrapping; // 允许垂直重复 texture.needsUpdate = true; // 应用修改 const material = new THREE.MeshBasicMaterial({ map: texture }); plane.material = material; // 应用到平面 ``` --- ### 完整代码示例 ```javascript // 创建平面 const planeGeometry = new THREE.PlaneGeometry(10, 5); // 宽10单位, 高5单位 const plane = new THREE.Mesh(planeGeometry, new THREE.MeshBasicMaterial()); scene.add(plane); // 加载纹理 const textureLoader = new THREE.TextureLoader(); textureLoader.load('texture.jpg', (texture) => { const image = texture.image; const imgWidth = image.width; const imgHeight = image.height; const imgAspect = imgWidth / imgHeight; // 纹理宽高比 const planeAspect = 10 / 5; // 平面宽高比 (width/height) // 动态调整 repeat 和 offset if (imgAspect > planeAspect) { const scale = planeAspect / imgAspect; texture.repeat.set(1, scale); texture.offset.set(0, (1 - scale) / 2); } else { const scale = imgAspect / planeAspect; texture.repeat.set(scale, 1); texture.offset.set((1 - scale) / 2, 0); } // 启用重复和偏移 texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping; texture.needsUpdate = true; // 更新平面材质 plane.material.map = texture; plane.material.needsUpdate = true; }); ``` --- ### 关键说明 1. **宽高比计算** - 纹理宽高比:$r_{\text{texture}} = \frac{\text{width}}{\text{height}}$ - 平面宽高比:$r_{\text{plane}} = \frac{\text{planeWidth}}{\text{planeHeight}}$ 2. **缩放逻辑** - 当 $r_{\text{texture}} > r_{\text{plane}}$ 时,垂直方向压缩纹理(`repeat.y = scale`) - 当 $r_{\text{texture}} < r_{\text{plane}}$ 时,水平方向压缩纹理(`repeat.x = scale`) 3. **居中偏移** `offset` 通过 `(1 - scale)/2` 计算,确保纹理始终居中显示。 --- ### 效果对比 | 场景 | 纹理宽高比 > 平面宽高比 | 纹理宽高比 < 平面宽高比 | |------|--------------------------|--------------------------| | **示意图** | ![宽纹理](https://example.com/wide-texture.png) | ![高纹理](https://example.com/tall-texture.png) | | **调整方式** | 垂直压缩,水平铺满 | 水平压缩,垂直铺满 | > 此方法确保了纹理比例不变形,类似 CSS 的 `background-size: contain` 效果[^1]。 --- ### 相关问题 1. **如何实现纹理平铺(Tiling)而不拉伸?** 设置 `wrapS/wrapT` 为 `THREE.RepeatWrapping` 后,通过 `repeat` 控制重复次数,例如 `texture.repeat.set(2, 3)` 表示水平重复2次、垂直重复3次。 2. **动态调整时如何避免纹理闪烁?** 修改纹理参数后需设置 `texture.needsUpdate = true` 和 `material.needsUpdate = true` 以强制刷新渲染。 3. **非平面物体(如立方体)如何应用此方法?** 需分别计算每个面的 UV 映射,可通过 `BoxGeometry` 的 `.attributes.uv` 手动修改 UV 坐标[^2]。 [^1]: 类似二维平面在三维空间中保持比例理,参考:[Two-dimensional floor plans that spring to life in three dimensions](https://example.com) [^2]: UV 映射的调整可参考:[使用变换工具精确放置对象](https://example.com)
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值