Realistic simulation of sphere rotation on a floor

2019-07-29 14:35发布

I'm trying to simulate a sphere rolling on a floor. For the simulation I'm using the good-old Papervision3D library of Flash AS3, but it actually doesn't matter, this is a pure geometry question.

Assuming that I have a Sphere3D object to which I can set the rotationX, rotationY and rotationZ properties, How can I calculate the rotation in every axis where this sphere is rolling on the floor?

For instance, Let's assume the sphere is in rest. Now it rolls 1 meter to the right. If I'm looking at this sphere from the top - I'll want to rotate it around the Z axis, 90 degrees. Then the sphere should roll "downwards" along the floor, so I'll want to rotate it around the X axis, but this problem is that in the meantime the X axis rotated itself when I rotated the sphere along the Z axis.

How can I solve this issue?

Thanks

1条回答
叛逆
2楼-- · 2019-07-29 15:10

If there is no sliding then:

  1. rotation axis

    will be parallel to your floor and perpendicular to your movement. So you can exploit cross product to get it. Let:


    n - floor normal vector
    t - movement direction parallel with floor (tangent)
    b - our rotation axis (binormal)

    so we can compute it as:

    b = cross(t,n) // cross product create perpendicular vector to both operands
    t /= |t|       // normalize to unit vector
    b /= |b|       // normalize to unit vector
    n /= |n|       // normalize to unit vector
    
  2. rotation speed

    this can be derived from arclength and speed vel [unit/s]. So if our sphere is of radius r then:

    ang*r = vel*t
    ang = vel*t/r // t=1.0 sec
    omg = vel/r   // [rad/sec]
    

    so we need to rotate our sphere by omg each second.

  3. rotation math

    Euler angles (your sequenced rotations X,Y,Z) are the worst thing for this I can think of as they will lead to singularities and weird stuff making this simple example horrible nightmare to implement. have you seen in a game or any 3D engine that suddenly you can not look as you expect, or randomly spin until you move/rotate differently or suddenly rotate by 180deg ... ? That are Euler angles singularities at work without proper handling...

    Quaternions are somewhat alien to most people (me included) as they do not work like we think. IIRC You can look at them as efficient way of computing 3x3 3D rotation matrix with less goniometric functions needed. As we now have much different computational power than 20 years ago theres not much point choosing them if you do not know them at all. Anyway they have also another advantages which are still relevant like you can interpolate between rotations etc.

    4x4 homogenuous transform matrices are your best choice. As their geometric representation is compatible with human abstract thinking (you can imagine what and how it is done hence you can construct your own matrices instead of having them as bunch of meaningless numbers).

    I strongly recommend to start with 3D 4x4 homogenuous transform matrices. So all the rest of this answer will be aimed to them.

  4. rotating

    Now There are 2 ways I know of how to achieve your rotation. Either use Rodrigues_rotation_formula and encode it as transform matrix or simply construct your own rotation matrix that will represent your sphere aligned to floor. direction of movement and rotation axis.

    The latter is much much simpler and we can do it directly as we already know the 3 basis vectors needed (t,b,n). What is left is only the sphere position which should be also known.

    So at start create a transform matrix (assuming OpenGL notation):

    | tx bx nx x0 |
    | ty by ny y0 |
    | tz bz nz z0 |
    |  0  0  0  1 |
    

    Where x0,y0,z0 is start position of your sphere aligned with your mesh. So if center point of your mesh is (0,0,0) then place your sphere r above the floor...

    Now just each elapsed time dt [sec] (like timer) multiply this matrix by incremental rotation matrix around y axis (as b is our rotation axis) and angle omg*dt [rad].

    We also need to translate our sphere by t*vel*dt so simply add this vector to matrix position or multiply our matrix with:

    | 1 0 0 tx*vel*dt |
    | 0 1 0 ty*vel*dt |
    | 0 0 1 tz*vel*dt |
    | 0 0 0         1 |
    

    And also render the scene again using our resulting matrix... This approach is nice as you can anytime change the direction of movement (you just remember the position and change the inner 3x3 rotation part of the matrix with new t,b,n vectors.

    However there is one disadvantage that such cumulative matrix will degrade the accuracy over time (as we are performing multiplication by floating numbers over and over on it without reset) so the matrix can deform over time. To avoid this is enough to recompute and set the t,b,n part of the matrix from time to time. I am used to do it each 128 rotations on 64bit double variables precision. It can be done also automatically (when you have no prior info about the axises) I am doing like this:

Also using matrices have different notations (row/column major order, multiplication order) which can affect the equations a bit (either reverse order of multiplication and/or using inverse matrices instead).

Now in case your 3D engine does not support matrices (which is highly unlikely) you would need to convert our resulting matrix back into Euler angles. That is doable by goniometrics but for that you would need to know the order of the angles.

In case of Sliding you need to go in reverse order. So first compute the rotations and then compute the direction of translation from the grip forces with floor and inertia. Which is a bit more complex and pure physics ...

[Edit1] rotundus style simple OpenGL/C++/VCL example

preview

Here simple control example using cumulative matrix (without the accuracy preservation):

//---------------------------------------------------------------------------
#include <vcl.h>            // VCL stuff (ignore)
#include <math.h>           // sin,cos,M_PI
#pragma hdrstop             // VCL stuff (ignore)
#include "Unit1.h"          // VCL stuff (header of this window)
#include "gl_simple.h"      // my GL init (source included)
//---------------------------------------------------------------------------
#pragma package(smart_init) // VCL stuff (ignore)
#pragma resource "*.dfm"    // VCL stuff (ignore)
TForm1 *Form1;              // VCL stuff (this window)
//---------------------------------------------------------------------------
// vector/matrix math
//---------------------------------------------------------------------------
void  vector_mul(double *c,double *a,double *b)         // c[3] = a[3] x b[3] (cross product)
    {
    double   q[3];
    q[0]=(a[1]*b[2])-(a[2]*b[1]);
    q[1]=(a[2]*b[0])-(a[0]*b[2]);
    q[2]=(a[0]*b[1])-(a[1]*b[0]);
    for(int i=0;i<3;i++) c[i]=q[i];
    }
//---------------------------------------------------------------------------
void matrix_mul_vector(double *c,double *a,double *b)   // c[3] = a[16]*b[3] (w=1)
    {
    double q[3];
    q[0]=(a[ 0]*b[0])+(a[ 4]*b[1])+(a[ 8]*b[2])+(a[12]);
    q[1]=(a[ 1]*b[0])+(a[ 5]*b[1])+(a[ 9]*b[2])+(a[13]);
    q[2]=(a[ 2]*b[0])+(a[ 6]*b[1])+(a[10]*b[2])+(a[14]);
    for(int i=0;i<3;i++) c[i]=q[i];
    }
//---------------------------------------------------------------------------
void  matrix_inv(double *a,double *b) // a[16] = (Pseudo)Inverse(b[16])
    {
    double x,y,z;
    // transpose of rotation matrix
    a[ 0]=b[ 0];
    a[ 5]=b[ 5];
    a[10]=b[10];
    x=b[1]; a[1]=b[4]; a[4]=x;
    x=b[2]; a[2]=b[8]; a[8]=x;
    x=b[6]; a[6]=b[9]; a[9]=x;
    // copy projection part
    a[ 3]=b[ 3];
    a[ 7]=b[ 7];
    a[11]=b[11];
    a[15]=b[15];
    // convert origin: new_pos = - new_rotation_matrix * old_pos
    x=(a[ 0]*b[12])+(a[ 4]*b[13])+(a[ 8]*b[14]);
    y=(a[ 1]*b[12])+(a[ 5]*b[13])+(a[ 9]*b[14]);
    z=(a[ 2]*b[12])+(a[ 6]*b[13])+(a[10]*b[14]);
    a[12]=-x;
    a[13]=-y;
    a[14]=-z;
    }
//---------------------------------------------------------------------------
double* matrix_ld      (double *p,double a0,double a1,double a2,double a3,double a4,double a5,double a6,double a7,double a8,double a9,double a10,double a11,double a12,double a13,double a14,double a15) {                       p[0]=a0; p[1]=a1; p[2]=a2; p[3]=a3; p[4]=a4; p[5]=a5; p[6]=a6; p[7]=a7; p[8]=a8; p[9]=a9; p[10]=a10; p[11]=a11; p[12]=a12; p[13]=a13; p[14]=a14; p[15]=a15; return p; }
//---------------------------------------------------------------------------
void  matrix_mul       (double *c,double *a,double *b)  // c[16] = a[16] * b[16]
    {
    double q[16];
    q[ 0]=(a[ 0]*b[ 0])+(a[ 1]*b[ 4])+(a[ 2]*b[ 8])+(a[ 3]*b[12]);
    q[ 1]=(a[ 0]*b[ 1])+(a[ 1]*b[ 5])+(a[ 2]*b[ 9])+(a[ 3]*b[13]);
    q[ 2]=(a[ 0]*b[ 2])+(a[ 1]*b[ 6])+(a[ 2]*b[10])+(a[ 3]*b[14]);
    q[ 3]=(a[ 0]*b[ 3])+(a[ 1]*b[ 7])+(a[ 2]*b[11])+(a[ 3]*b[15]);
    q[ 4]=(a[ 4]*b[ 0])+(a[ 5]*b[ 4])+(a[ 6]*b[ 8])+(a[ 7]*b[12]);
    q[ 5]=(a[ 4]*b[ 1])+(a[ 5]*b[ 5])+(a[ 6]*b[ 9])+(a[ 7]*b[13]);
    q[ 6]=(a[ 4]*b[ 2])+(a[ 5]*b[ 6])+(a[ 6]*b[10])+(a[ 7]*b[14]);
    q[ 7]=(a[ 4]*b[ 3])+(a[ 5]*b[ 7])+(a[ 6]*b[11])+(a[ 7]*b[15]);
    q[ 8]=(a[ 8]*b[ 0])+(a[ 9]*b[ 4])+(a[10]*b[ 8])+(a[11]*b[12]);
    q[ 9]=(a[ 8]*b[ 1])+(a[ 9]*b[ 5])+(a[10]*b[ 9])+(a[11]*b[13]);
    q[10]=(a[ 8]*b[ 2])+(a[ 9]*b[ 6])+(a[10]*b[10])+(a[11]*b[14]);
    q[11]=(a[ 8]*b[ 3])+(a[ 9]*b[ 7])+(a[10]*b[11])+(a[11]*b[15]);
    q[12]=(a[12]*b[ 0])+(a[13]*b[ 4])+(a[14]*b[ 8])+(a[15]*b[12]);
    q[13]=(a[12]*b[ 1])+(a[13]*b[ 5])+(a[14]*b[ 9])+(a[15]*b[13]);
    q[14]=(a[12]*b[ 2])+(a[13]*b[ 6])+(a[14]*b[10])+(a[15]*b[14]);
    q[15]=(a[12]*b[ 3])+(a[13]*b[ 7])+(a[14]*b[11])+(a[15]*b[15]);
    for(int i=0;i<16;i++) c[i]=q[i];
    }
//---------------------------------------------------------------------------
// old style GL sphere mesh
//---------------------------------------------------------------------------
const int nb=15;            // slices
const int na=nb<<1;         // points per equator
class sphere
    {
public:
    // movement
    double r;               // sphere radius [units]
    double m[16];           // sphere direct matrix
    double vel;             // actual velocity [unit/sec] in forward direction
    void turn(double da)    // turn left/right by angle [deg]
        {
        // rotate m around global Z axis
        da*=M_PI/180.0; // [deg] -> [rad]
        double c=cos(da),s=sin(da),xyz[16];
        matrix_ld(xyz, c,-s, 0, 0,  // incremental rotation around Z
                       s, c, 0, 0,
                       0, 0, 1, 0,
                       0, 0, 0, 1);
        matrix_mul_vector(m+0,xyz,m+0); // transform all basis vectors of m from xyz [LCS] into world [GCS]
        matrix_mul_vector(m+4,xyz,m+4);
        matrix_mul_vector(m+8,xyz,m+8);
        }
    void update(double dt)  // simulate dt [sec] time is elapsed
        {
        if (fabs(vel)<1e-6) return;     // ignore stopped case
        // compute unit tangent (both vectors are unit so no normalization needed)
        double t[3]={ 0.0,0.0,1.0 };    // tangent is perpendiculr to global Z (turning axis)
        vector_mul(t,t,m+0);            // and perpendicular to local X (movement rotation axis)
        // update position
        for (int i=0;i<3;i++) m[12+i]+=vel*dt*t[i];
        // update rotation
        double da=vel*dt/r,c=cos(da),s=sin(da);
        double xyz[16];
        matrix_ld(xyz, 1, 0, 0, 0,
                       0, c,-s, 0,
                       0, s, c, 0,
                       0, 0, 0, 1);
        matrix_mul(m,xyz,m);
        }
    // mesh and rendering
    bool _init;             // has been initiated ?
    GLfloat pos[na][nb][3]; // vertex
    GLfloat nor[na][nb][3]; // normal
    GLfloat txr[na][nb][2]; // texcoord
    GLuint  txrid;          // texture id
    sphere() { _init=false; txrid=0; }
    ~sphere() { if (_init) glDeleteTextures(1,&txrid); }
    void init(GLfloat r,AnsiString texture);        // call after OpenGL is already working !!!
    void draw();
    };
void sphere::init(GLfloat _r,AnsiString texture)
    {
    GLfloat x,y,z,a,b,da,db;
    GLfloat tx0,tdx,ty0,tdy;// just correction if CLAMP_TO_EDGE is not available
    int ia,ib;
    // varables
    r=_r; vel=0.0;
    for (ia=0;ia<16;ia++ ) m[ia]=0.0;
    for (ia=0;ia<16;ia+=5) m[ia]=1.0;
    // mesh
    if (!_init) { _init=true; glGenTextures(1,&txrid); }
    // a,b to texture coordinate system
    tx0=0.0;
    ty0=0.5;
    tdx=0.5/M_PI;
    tdy=1.0/M_PI;

    // load texture to GPU memory
    if (texture!="")
        {
        Byte q;
        unsigned int *pp;
        int xs,ys,x,y,adr,*txr;
        union { unsigned int c32; Byte db[4]; } c;
        Graphics::TBitmap *bmp=new Graphics::TBitmap;   // new bmp
        bmp->LoadFromFile(texture); // load from file
        bmp->HandleType=bmDIB;      // allow direct access to pixels
        bmp->PixelFormat=pf32bit;   // set pixel to 32bit so int is the same size as pixel
        xs=bmp->Width;              // resolution should be power of 2
        ys=bmp->Height;
        txr=new int[xs*ys];
        for(adr=0,y=0;y<ys;y++)
            {
            pp=(unsigned int*)bmp->ScanLine[y];
            for(x=0;x<xs;x++,adr++)
                {
                // rgb2bgr and copy bmp -> txr[]
                c.c32=pp[x];
                q      =c.db[2];
                c.db[2]=c.db[0];
                c.db[0]=q;
                txr[adr]=c.c32;
                }
            }
        glEnable(GL_TEXTURE_2D);
        glBindTexture(GL_TEXTURE_2D,txrid);
        glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S,GL_CLAMP);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T,GL_CLAMP);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER,GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER,GL_LINEAR);
        glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE,GL_MODULATE);
        glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, xs, ys, 0, GL_RGBA, GL_UNSIGNED_BYTE, txr);
        glDisable(GL_TEXTURE_2D);
        delete bmp;
        delete[] txr;

        // texture coordinates by 1 pixel from each edge (GL_CLAMP_TO_EDGE)
        tx0+=1.0/GLfloat(xs);
        ty0+=1.0/GLfloat(ys);
        tdx*=GLfloat(xs-2)/GLfloat(xs);
        tdy*=GLfloat(ys-2)/GLfloat(ys);
        }
    // correct texture coordinate system (invert x)
    tx0=1.0-tx0; tdx=-tdx;

    da=(2.0*M_PI)/GLfloat(na-1);
    db=     M_PI /GLfloat(nb-1);
    for (ib=0,b=-0.5*M_PI;ib<nb;ib++,b+=db)
    for (ia=0,a= 0.0     ;ia<na;ia++,a+=da)
        {
        x=cos(b)*cos(a);
        y=cos(b)*sin(a);
        z=sin(b);
        nor[ia][ib][0]=x;
        nor[ia][ib][1]=y;
        nor[ia][ib][2]=z;
        pos[ia][ib][0]=r*x;
        pos[ia][ib][1]=r*y;
        pos[ia][ib][2]=r*z;
        txr[ia][ib][0]=tx0+(a*tdx);
        txr[ia][ib][1]=ty0+(b*tdy);
        }
    }
void sphere::draw()
    {
    if (!_init) return;
    int ia,ib0,ib1;

    glMatrixMode(GL_MODELVIEW);
    glPushMatrix();
    glMultMatrixd(m);

    glEnable(GL_TEXTURE_2D);
    glBindTexture(GL_TEXTURE_2D,txrid);
    glEnable(GL_CULL_FACE);
    glFrontFace(GL_CW);
    glEnable(GL_LIGHTING);
    glEnable(GL_LIGHT0);

    glColor3f(1.0,1.0,1.0);
    for (ib0=0,ib1=1;ib1<nb;ib0=ib1,ib1++)
        {
        glBegin(GL_QUAD_STRIP);
        for (ia=0;ia<na;ia++)
            {
            glNormal3fv  (nor[ia][ib0]);
            glTexCoord2fv(txr[ia][ib0]);
            glVertex3fv  (pos[ia][ib0]);
            glNormal3fv  (nor[ia][ib1]);
            glTexCoord2fv(txr[ia][ib1]);
            glVertex3fv  (pos[ia][ib1]);
            }
        glEnd();
        }
    glDisable(GL_TEXTURE_2D);
    glDisable(GL_CULL_FACE);
    glDisable(GL_LIGHTING);
    glDisable(GL_LIGHT0);
/*
    // local axises
    double q=1.5*r;
    glBegin(GL_LINES);
    glColor3f(1.0,0.0,0.0); glVertex3d(0.0,0.0,0.0); glVertex3d(q,0.0,0.0);
    glColor3f(0.0,1.0,0.0); glVertex3d(0.0,0.0,0.0); glVertex3d(0.0,q,0.0);
    glColor3f(0.0,0.0,1.0); glVertex3d(0.0,0.0,0.0); glVertex3d(0.0,0.0,q);
    glEnd();
*/
    glMatrixMode(GL_MODELVIEW);
    glPopMatrix();
    }
//---------------------------------------------------------------------------
// rendring
bool _redraw=false;
double ieye[16];            // camera inverse matrix
sphere obj;
// key codes for controling (Arrows + Space)
WORD key_left =37;
WORD key_right=39;
WORD key_up   =38;
WORD key_down =40;
// key pressed state
bool _left =false;
bool _right=false;
bool _up   =false;
bool _down =false;
//---------------------------------------------------------------------------
void draw_map()
    {
    int i,j;
    double u,v,p[3],dp[3];

    // here 3D view must be already set (modelview,projection)
    glDisable(GL_CULL_FACE);

    // [draw 3D map]
    const int n=30;                 // map size
    double p0[3]={0.0,0.0,0.0};     // map start point
    double du[3]={1.0,0.0,0.0};     // map u step (size of grid = 1.0 )
    double dv[3]={0.0,1.0,0.0};     // map v step (size of grid = 1.0 )
    glColor3f(0.5,0.7,1.0);
    glBegin(GL_LINES);
    for (j=0;j<=n;j++)
        {
        for (i=0;i<3;i++) p[i]=p0[i]+(double(j)*du[i])+(double(0)*dv[i]); glVertex3dv(p);
        for (i=0;i<3;i++) p[i]=p0[i]+(double(j)*du[i])+(double(n)*dv[i]); glVertex3dv(p);
        for (i=0;i<3;i++) p[i]=p0[i]+(double(0)*du[i])+(double(j)*dv[i]); glVertex3dv(p);
        for (i=0;i<3;i++) p[i]=p0[i]+(double(n)*du[i])+(double(j)*dv[i]); glVertex3dv(p);
        }
    glEnd();
    }
//---------------------------------------------------------------------------
void gl_draw()
    {
    _redraw=false;
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    glMatrixMode(GL_MODELVIEW);
    glLoadMatrixd(ieye);    // inverse camera matrix

    obj.draw();
    draw_map();

    glFlush();
    SwapBuffers(hdc);
    }
//---------------------------------------------------------------------------
__fastcall TForm1::TForm1(TComponent* Owner):TForm(Owner)
    {
    // this is called on window startup
    gl_init(Handle);                // init OpenGL 1.0
    glMatrixMode(GL_MODELVIEW);     // set camera to vew our map
    glLoadIdentity;
    glTranslatef(-15.0,-5.0,-10.5); // "centered" position above the map
    glRotatef(-60.0,1.0,0.0,0.0);   // rotate view to be more parallel to plane
    glGetDoublev(GL_MODELVIEW_MATRIX,ieye); // store result

    // ini obj
    obj.init(1.0,"ball.bmp");   // radius texture and mesh
    obj.m[12]=10.0;             // position (x,y,z)
    obj.m[13]=10.0;
    obj.m[14]=+obj.r;
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::FormDestroy(TObject *Sender)
    {
    // this is called before window exits
    gl_exit();                      // exit OpenGL
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::FormResize(TObject *Sender)
    {
    // this is called on each window resize (and also after startup)
    gl_resize(ClientWidth,ClientHeight);
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::FormPaint(TObject *Sender)
    {
    // this is called whnewer app needs repaint
    gl_draw();
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::FormKeyDown(TObject *Sender, WORD &Key, TShiftState Shift)
    {
    // on key down event
    if (Key==key_left ) _left =true;
    if (Key==key_right) _right=true;
    if (Key==key_up   ) _up   =true;
    if (Key==key_down ) _down =true;
    Key=0;  // key is handled
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::FormKeyUp(TObject *Sender, WORD &Key, TShiftState Shift)
    {
    // on key release event
    if (Key==key_left ) _left =false;
    if (Key==key_right) _right=false;
    if (Key==key_up   ) _up   =false;
    if (Key==key_down ) _down =false;
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::FormMouseActivate(TObject *Sender, TMouseButton Button, TShiftState Shift, int X, int Y, int HitTest, TMouseActivate &MouseActivate)
    {
    _left =false; // clear key flags after focus change
    _right=false; // just to avoid constantly "pressed" keys
    _up   =false; // after window focus swaping during key press
    _down =false; // many games are ignoring this and you need to
    }
//---------------------------------------------------------------------------
void __fastcall TForm1::Timer1Timer(TObject *Sender)
    {
   // here movement and repaint timer handler (I have 20ms interval)
    double dt=0.001*double(Timer1->Interval);   // timer period [sec]
    double da=90.0*dt;  // angular turn speed in [deg/sec]
    double dp=10.0*dt;  // camera movement speed in [units/sec]
    double dv=10.0*dt;  // sphere acceleration [units/sec^2]
    // control object
    if (_left ) { _redraw=true; obj.turn(-da); }
    if (_right) { _redraw=true; obj.turn(+da); }
    if (_up   ) { _redraw=true; obj.vel+=dv; }
    if (_down ) { _redraw=true; obj.vel-=dv; }
    // simulate the ball movement
    obj.update(dt); _redraw=true;
    // render if needed
    if (_redraw) gl_draw();
    }
//---------------------------------------------------------------------------

Its an empty single form VCL app with single 20ms timer on it. In order to port to your environment just ignore the VCL stuff, mimic the relevant events of the app and port rendering to your components/style/api. The only important stuff is just the sphere class marked as // movement and the timer event Timer1Timer(TObject *Sender). All the rest is just rendering and keyboard handling ... Which I susspect you already got handled on your own ...

The preview shows movement while I control the ball with arrows:

up/down - accelerate/decelerate
left/right - turn left/right in respect to forward direction around normal to surface

Here texture I used (drawed in mspaint by hand so it might not be pixel perfect symmetrical...)

texture

The gl_simple.h of mine can be found in here:

查看更多
登录 后发表回答