标签 projection 下的文章

关系代数、SQL语句和Go语言示例

本文永久链接 – https://tonybai.com/2023/11/15/relational-algebra-and-sql-with-go-examples

近些年,数据库领域发展日新月异,除传统的关系型数据库外,还出现了许多新型的数据库,比如:以HBase、Cassandra、MongoDB为代表的NoSQL数据库,以InfluxDB、TDEngine为代表的时序数据库,以Neo4J、Dgraph为代表的图数据库,以Redis、Memcached等为代表的内存数据库,以Milvus为代表的向量数据库,以CockroachDB、TiDB为代表的HTAP融合数据库以及云原生数据库等。各类型数据库都有自己的优势,开发者可以根据应用场景选择最合适的数据库。

不过,关系型数据库依旧是当今最流行的数据库管理系统,广泛应用于企业应用,也是大多数数应用开发人员日常接触最多的一种数据库类型。关系型数据库通过关系模型和关系代数的理论基础,实现了对关系数据的高效组织和操作。但许多开发人员在使用SQL进行数据库开发时,往往感到关系代数晦涩难懂,对SQL语句的语义理解不透彻,这给数据库应用开发带来了困难。

在这篇文章中,我们就来研究一下关系模型和关系代数,探究其与SQL语句的对应关系,并用Go语言代码示例实现相关查询,期望能帮助读者增进对关系数据库的理解,减轻数据库开发痛点,提高数据库应用能力。

1. 关系模型(Relational Model)

20世纪70年代,IBM研究员E.F. Codd在“A Relational Model of Data for Large Shared Data Banks”这篇论文中提出了关系模型的概念。随后,E.F.Codd又陆续发表了多篇文章,用数学理论奠定了关系数据库的基础,为关系数据库建立了一个数据模型 —— 关系数据模型

关系模型基于谓词逻辑和集合论,有严格的数学基础,提供了高级别的数据抽象层次,并不规定数据存取的具体过程,而是交由DBMS(数据库管理系统)自己实现。

关系模型之所以成为DBMS领域的主流模型,正是由于其非常简单(相较于更早的网络模型(network model)和层次模型(hierarchical model)),下面是关系模型中定义的一些概念:

  • 关系(Relation)

E.F.Codd的论文对关系(Relation)的定义是这样的:“这里的关系是指公认的数学意义上的关系。给定集合S1, S2, … ,Sn(不一定互不相关),如果 R是由n元组(n-tuples)组成的集合,其中每个元组的第一个元素来自S1,第二个元素来自S2,以此类推,那么R就是这n个集合(S1~Sn)上的一个关系”。

不用你说,我也知道这段文字太过抽象!下面我尽力用一个图来呈现一下Relation的含义:

我们看到,关系(Relation)是一个集合,实质上是一个“二维表格结构”,把上图中不属于R中的元组去掉,看起来可能更清晰一些:

这个结构中的每一行就是1个n元组(n-tuples),列则是S1到Sn,一共n个列。n元组中的数据依次分别来自S1、S2、…Sn。

  • 元组(Tuple)

关系(Relation)这个“二维表格结构”中的每一个n元组,即每一行,被称作元组(Tuple)。

  • 属性(Attribute)

关系(Relation)这个“二维表格结构”中的每一列(Sn)被称作一个属性(Attribute)。

  • 域(Domain)

属性可能取值的范围被称为该属性的域,以图中属性S3为例,S3-e1、S3-e2一直到S3-ek都在该属性的域中,显然{S3-e1, S3-e2, …, S3-ek}这个集合是属性S3的域的一个子集。有个特殊的值null是所有域的一个成员,它一般表示值为”unknown”。

论文在定义关系模型时,还定义了一些模型的额外特征,比如:

  • 元组的顺序是不重要的;
  • 所有的元组(行)是不同的;
  • … …

有了关系模型的定义,接下来就可以在模型基础上定义以关系操作对象的运算了,这种运算的集合就构成了关系代数

2. 关系代数(Relational Algebra)

关系代数由一系列操作组成,这些操作将一个或两个关系作为输入,并产生一个新的关系作为结果。概括来说就是关系代数的运算通过输入有限数量的关系进行运算,运算结果仍为关系。

关系代数定义了一些基本关系运算和扩展关系运算,其中基本关系运算包括:

  • 选择(Selection)
  • 投影(Projection)
  • 笛卡儿积(Cartesian Product)
  • 连接(Join)
  • 除(Division)
  • 关系并(Union)
  • 关系差(Difference)

扩展运算包括:

  • 关系交(Intersection)
  • 重命名(Rename)
  • … …

注:关于关系代数的基本关系运算与扩展关系运算的定义在不同书籍里或资料里有所不同。比如在《数据库查询优化器的艺术》一书中,作者认为:关系代数(Relational Algebra)是在集合代数基础上发展起来的,其数据的操作可分为传统的集合运算和专门的关系运算两类。传统的集合运算包括并(Union)、差(Difference)、交(Intersection)和笛卡儿积(Cartesion Product),专门的关系运算包括选择(Select)、投影(Project)、连接(Join)和除(Division)。关系代数中五个基本的操作并(Union)、差(Difference)、笛卡儿积(Cartesion Product)、选择(Select)和投影(Project)组成了关系代数完备的操作集。

关系代数中的一些操作(如选择、投影和重命名操作)被称为一元操作(unary operation),因为它们只对一个关系进行操作。其他操作,如关系并、笛卡尔积和关系差,则是对一对关系进行操作,因此称为二元操作(binary operation):

到这里,我们知道了关系模型的概念定义以及基于关系的代数运算都有哪些。那么关系模型、代数运算与我们日常的关系数据库以及我们使用的SQL语句的对应关系是什么呢?接下来我们就逐一说明一下。

3. 关系模型与关系数据库实现的对应关系

讲到这里,其实大家心里或多或少都有个数了,关系模型与关系数据库实现中概念的对应关系十分明显:

  • 关系型数据库中的表(table)对应关系模型中的关系(relation);
  • 关系型数据库中的表的记录行(row)对应关系模型中的元组(triple);
  • 关系型数据库中的表的列(column)对应关系模型中的属性(attribute);
  • 关系型数据库中的表的列数据类型(column type)对应关系模型中的属性的域(domain)。

当然关系型数据库与关系模型还有一些对应关系不是本文重点,比如:

  • 关系模型中的关系完整性约束(如实体完整性、参照完整性等)对应于关系数据库中的约束(如主键约束、外键约束等)。
  • 关系模型中的范式理论(如第一范式、第二范式等)对应于关系数据库中的数据规范化过程。

我们下面要关注的一个最重要的对应就是关系模型中的关系代数运算对应于关系数据库中的查询操作,我们可以使用SQL语句来实现关系模型中的运算,这也是下面我们要重点说明的内容,通过了解SQL语句背后实现的关系代数运算的本质,将可以帮助我们更好地理解关系模型,对后续数据库设计以及数据操作的高效性都大有裨益。

4. 关系代数与SQL的对应关系

终于来到最重要的内容了,其实就是通过SQL如何实现关系代数的操作,这也是作为应用开发人员最最关心的内容。

4.1 预先定义的关系

为了便于后续的说明,这里我们预先定义一些关系(表),它们将用在后续说明各个关系运算符的示例中,这些表见下图:

这里包含一个学生表(Students)、一个课程清单表(Courses)以及两年年度的选课表:CourseSelection2022和CourseSelection2023(注:这里不讨论表设计的合理性)。

文中使用sqlite做为数据库管理系统(DBMS)的代表,主要是为了简单,SQL标准的兼容性也不错。下面的Go代码用于创建上图中的表并插入样例数据:

// relational-algebra-examples/create_database/main.go

package main

import (
    "database/sql"
    "fmt"

    _ "modernc.org/sqlite"
)

func createTable(db *sql.DB, sqlStmt string) error {
    stmt, err := db.Prepare(sqlStmt)
    if err != nil {
        fmt.Println("prepare statement error:", err)
        return err
    }

    _, err = stmt.Exec()
    if err != nil {
        fmt.Println("exec prepared statement error:", err)
        return err
    }

    return nil
}

func createTables(db *sql.DB) error {
    // 创建Students表
    err := createTable(db, `CREATE TABLE IF NOT EXISTS Students (
    Sno INTEGER PRIMARY KEY,
    Sname TEXT,
    Gender TEXT,
    Age INTEGER
  )`)
    if err != nil {
        fmt.Println("create table Students error:", err)
        return err
    }

    // 创建Courses表
    err = createTable(db, `CREATE TABLE IF NOT EXISTS Courses (
    Cno INTEGER PRIMARY KEY,
    Cname TEXT,
    Credit INTEGER
  )`)
    if err != nil {
        fmt.Println("create table Courses error:", err)
        return err
    }

    // 2022选课表
    err = createTable(db, `CREATE TABLE CourseSelection2022 (
  Sno INTEGER,
  Cno INTEGER,
  Score INTEGER,

  PRIMARY KEY (Sno, Cno),
  FOREIGN KEY (Sno) REFERENCES Students(Sno),
  FOREIGN KEY (Cno) REFERENCES Courses(Cno)
)`)
    if err != nil {
        fmt.Println("create table CourseSelection2022 error:", err)
        return err
    }

    // 2023选课表
    err = createTable(db, `CREATE TABLE CourseSelection2023 (
  Sno INTEGER,
  Cno INTEGER,
  Score INTEGER,

  PRIMARY KEY (Sno, Cno),
  FOREIGN KEY (Sno) REFERENCES Students(Sno),
  FOREIGN KEY (Cno) REFERENCES Courses(Cno)
)`)

    if err != nil {
        fmt.Println("create table CourseSelection2023 error:", err)
        return err
    }
    return nil
}

func checkErr(err error) {
    if err != nil {
        panic(err)
    }
}

func insertData(db *sql.DB) {
    // 向Students表插入数据
    stmt, err := db.Prepare("INSERT INTO Students VALUES (?, ?, ?, ?)")
    checkErr(err)

    _, err = stmt.Exec(1001, "张三", "M", 20)
    checkErr(err)
    _, err = stmt.Exec(1002, "李四", "F", 18)
    checkErr(err)
    _, err = stmt.Exec(1003, "王五", "M", 19)
    checkErr(err)

    // 向Courses表插入数据
    stmt, err = db.Prepare("INSERT INTO Courses VALUES (?, ?, ?)")
    checkErr(err)

    _, err = stmt.Exec(1, "数据库", 4)
    checkErr(err)
    _, err = stmt.Exec(2, "数学", 2)
    checkErr(err)
    _, err = stmt.Exec(3, "英语", 3)
    checkErr(err)

    // 插入2022选课数据
    stmt, _ = db.Prepare("INSERT INTO CourseSelection2022 VALUES (?, ?, ?)")
    _, err = stmt.Exec(1001, 1, 85)
    checkErr(err)
    _, err = stmt.Exec(1001, 2, 80)
    checkErr(err)
    _, err = stmt.Exec(1002, 1, 83)
    checkErr(err)
    _, err = stmt.Exec(1003, 1, 76)
    checkErr(err)
    // ...

    // 插入2023选课数据
    stmt, _ = db.Prepare("INSERT INTO CourseSelection2023 VALUES (?, ?, ?)")
    stmt.Exec(1001, 3, 75)
    checkErr(err)
    stmt.Exec(1002, 2, 81)
    checkErr(err)
    stmt.Exec(1003, 3, 86)
    checkErr(err)
}

func main() {
    db, err := sql.Open("sqlite", "../test.db")
    defer db.Close()
    if err != nil {
        fmt.Println("open test.db error:", err)
        return
    }

    err = createTables(db)
    if err != nil {
        fmt.Println("create table error:", err)
        return
    }

    insertData(db)
}

这里我们使用了cznic大神实现并开源的modernc.org/sqlite,这是一个纯Go的sqlite3数据库driver。Go社区另一个广泛使用的sqlite3的driver库为go-sqlite3,只不过go-sqlite3是使用cgo对sqlite3 C库的封装。

执行上面go代码,便可以建立一个名为test.db的sqlite数据库,我们通过sqlite官方的命令行工具(cli)也可以与该数据库文件交互(这里我们使用的是容器版cli),比如:

$docker pull  nouchka/sqlite3

// cd到test.db文件路径下

$docker run -v {test.db文件所在目录的绝对路径}:/root/db -it nouchka/sqlite3
SQLite version 3.40.1 2022-12-28 14:03:47
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> .open ./test.db
sqlite> .databases
main: /root/db/test.db r/w
sqlite> .tables
CourseSelection2022  Courses
CourseSelection2023  Students
sqlite>

接下来,我们就先从关系代数运算中最容易理解的一元运算符开始说起。

4.2. 选择(Selection)

“选择”是一元关系运算,它的运算符为σ,语义如下:

R' = σ[p](R) = {t | t∈R ∩ p(t) = true } // 这里用[p]表示数学符号的下标

其中R为关系,t为元组,p是谓词(predicate)表达式的组合,可以由一个或多个谓词表达式构成。

这个语义相对好理解一些:它对R的操作结果依然是关系R’,即一个新元组集合,这个元组集合中的元组来自R,但必须满足p(t) = true的条件。说直白一些,就是选择满足给定条件的元组。下面是一个“选择”操作的示意图:

我们可以用下面最常见的SQL语句实现对单一关系(表)的选择运算:

SELECT * FROM R WHERE p(t) = true;

对应Go示例的代码片段如下:

// relational-algebra-examples/query/main.go

func doSelection(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM CourseSelection2022 where score >= 80") // p(t)为score >= 80
    var selections []CourseSelection
    for rows.Next() {
        var s CourseSelection
        rows.Scan(&s.Sno, &s.Cno, &s.Score)
        selections = append(selections, s)
    }
    fmt.Println(selections)
}

输出结果为:

[{1001 1 85} {1001 2 80} {1002 1 83}]

4.3 投影(Projection)

“投影”也是一元关系运算,它的运算符为∏,语义如下:

R' = ∏[A1,A2,...,An](R) = {t[A1,A2,...,An]| t∈R } // 这里A1,A2,...,An表示从R中取出的列名

显然和“选择”通过谓词表达式选元组不同,“投影”选择一个关系中的指定列(A1,A2,…,An),即选择需要的属性。下面是其运算过程的示意图:

“投影”对应的SQL语句也是我们最熟悉的语句:

SELECT A1, A2, ..., An FROM R;

对应Go示例的代码片段如下:

// relational-algebra-examples/query/main.go

func doProjection(db *sql.DB) {
    rows, _ := db.Query("SELECT Sno, Sname FROM Students") // A1 = Sno, A2 = Sname
    var students []Student
    for rows.Next() {
        var s Student
        rows.Scan(&s.Sno, &s.Sname)
        students = append(students, s)
    }
    fmt.Println(students)
}

输出结果为:

[{1001 张三  0} {1002 李四  0} {1003 王五  0}]

不过要注意的是:取消某些关系列后可能出现重复行,违反了关系的定义(关系是一个元组的集合),因此必须检查并去除结果关系中重复的元组。

4.4 运算符的组合(Composition)

关系运算的输入是关系,结果也是一个关系,因此我们可以将关系运算符组合成一个更复杂的关系运算符表达式来实现更复杂的运算。比如将上面的两个一元关系运算符组合在一起“先选元组,再选属性”:

R' = ∏[A1,A2,...,An](σ[p](R))

其运算过程如下图所示:

上述运算符组合对应的SQL语句如下:

SELECT A1, A2, ..., An FROM R where p(t) = true;

对应Go示例的代码片段如下:

// relational-algebra-examples/query/main.go

func doCompositionOperation(db *sql.DB) {
    rows, _ := db.Query("SELECT Sno, Sname FROM Students where age >= 20")
    var students []Student
    for rows.Next() {
        var s Student
        rows.Scan(&s.Sno, &s.Sname)
        students = append(students, s)
    }
    fmt.Println(students)
}

输出结果为:

[{1001 张三  0}]

无论是选择运算还是投影运算,亦或是组合之后的运算,理解起来都相对容易,因为只涉及一个“关系”。接下来我们就看一下涉及两个关系的二元运算符,我们先来看看集合运算

4.5 关系交(Intersection)

如果没有记错,我们是在高中学习的集合代数。那时定义两个集合的交集运算是这样的:

对于集合A和B,其交运算(Intersction)为:

A ∩ B = { x | x ∈ A且 x ∈ B}

用一个一维空间的数的集合的例子来说,就是当A = {1, 2, 3, 4, 5},B = { 3, 5, 6, 9}时,A ∩ B = {3, 5}。我们通常用维恩图来示意集合运算:

在关系模型中,元组是一维集合,关系是元组的集合,即是一个二维集合,那么基于关系的交运算就要有一个前提:那就是参与运算的两个关系的属性必须是兼容的

两个关系的属性兼容需满足以下条件:

  • 属性数量相同

两个关系中的属性数量必须相同。

  • 属性类型相同或可转换

两个关系中对应位置的属性类型必须相同或可以通过类型转换进行兼容。例如,一个关系中的属性类型是整数,而另一个关系中的属性类型是浮点数,这种情况下属性类型是兼容的,因为整数可以隐式转换为浮点数。

  • 属性名称可以不同

两个关系中对应位置的属性名称可以不同,只要它们的属性类型兼容即可。属性名称的不同不会影响属性兼容性。

在关系模型中,两个关系的属性兼容性是判断两个关系是否可以进行某些操作(包括集合操作)的重要条件之一。

回到集合运算,如果两个关系的属性不兼容,则这两个关系无法进行集合运算,比如Students表和Courses表的属性个数不同,如果对它们进行关系交运算,会导致报错:

SELECT * FROM Students INTERSECT SELECT * FROM Courses;
Parse error: SELECTs to the left and right of INTERSECT do not have the same number of result columns

介绍完集合运算的前提后,我们再来看关系交运算,其语义入下:

R' = R1 ∩ R2

即两个关系R1和R2在属性兼容的前提下进行关系交运算的结果为返回两个关系中相同的元组。

关系交运算对应的SQL语句如下:

SELECT * FROM R1 INTERSECT SELECT * FROM R2;

对应Go示例的代码片段如下:

// relational-algebra-examples/query/main.go

func doIntersection(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM CourseSelection2022 INTERSECT SELECT * FROM CourseSelection2023")
    var selections []CourseSelection
    for rows.Next() {
        var s CourseSelection
        rows.Scan(&s.Sno, &s.Cno, &s.Score)
        selections = append(selections, s)
    }
    fmt.Println(selections)
}

由于CourseSelection2022和CourseSelection2023这两个关系没有相同元组,所以上述Go程序输出的结果为空。

4.6 关系并(Union)

和关系交一样,两个关系进行关系并运算的前提也是属性兼容。关系并运算的语义如下:

R' = R1 ∪ R2

即两个关系R1和R2在属性兼容的前提下进行关系并运算的结果为返回两个关系中的所有元组,但要去除重复元组。

关系并对应的SQL语句如下:

SELECT * FROM R1 UNION SELECT * FROM R2;

对应Go示例的代码片段如下:

// relational-algebra-examples/query/main.go

func doUnion(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM CourseSelection2022 UNION SELECT * FROM CourseSelection2023")
    var selections []CourseSelection
    for rows.Next() {
        var s CourseSelection
        rows.Scan(&s.Sno, &s.Cno, &s.Score)
        selections = append(selections, s)
    }
    fmt.Println(selections)
}

CourseSelection2022和CourseSelection2023这两个关系没有重复元组,所有关系并运算后得到的结果关系中包含了这两个关系的全部元组,上述程序的输出结果为:

[{1001 1 85} {1001 2 80} {1001 3 75} {1002 1 83} {1002 2 81} {1003 1 76} {1003 3 86}]

4.7 关系差(Difference)

在集合代数中,对于集合A和B,其差运算为:

A - B = { x | x ∈ A且 x ∉ B}

即从A集合中排除掉B集合中的元素。

在关系模型中,关系差运算即是从一个关系中排除另一个关系中的元组,其语义如下:

R' = R1-R2={t|t∈R1 ∩ t∉R2} // t为关系中的元组

在SQL中,我们可以用NOT IN实现:

SELECT * FROM R1 WHERE A1 NOT IN (SELECT A1 FROM R2 WHERE 条件)

下面是对应的Go语言代码片段:

// relational-algebra-examples/query/main.go

func doDifference(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM CourseSelection2022 WHERE Cno NOT IN (SELECT Cno FROM CourseSelection2023)")
    var selections []CourseSelection
    for rows.Next() {
        var s CourseSelection
        rows.Scan(&s.Sno, &s.Cno, &s.Score)
        selections = append(selections, s)
    }
    fmt.Println(selections)
}

这段示例的含义是选出CourseSelection2022的元组,但去掉Cno值在CourseSelection2023出现过的元组。下面是运行结果:

[{1001 1 85} {1002 1 83} {1003 1 76}]

注意:关系差运算的前提也是两个关系的属性兼容。

最后看看略复杂的二元运算符:笛卡尔积和连接。

4.8 笛卡尔积(Cartesian-product)

在关系代数中,关系积,即笛卡尔积(Cartesian Product)这种运算(也被称为关系叉乘)用于取两个关系的所有可能的组合。它的数学语义可以描述为:给定关系R1和R2,它们的笛卡尔积结果是一个新的关系,其中的元组由R1中的每个元组与R2中的每个元组的组合构成。

在SQL中,笛卡尔积可以通过使用CROSS JOIN关键字来实现:

SELECT * FROM R1 CROSS JOIN R2;

也可以通过下面SQL语句来实现:

SELECT R1.*, R1.* FROM R1, R2;

对应的Go代码片段如下:

// relational-algebra-examples/query/main.go

// StudentCourse结果
type StudentCourse struct {
    Sno    int
    Sname  string
    Gender string
    Age    int
    Cno    int
    Cname  string
    Credit int
}

func doCartesianProduct(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM Students CROSS JOIN Courses")
    // rows, _ := db.Query("SELECT Students.*, Courses.* FROM Students, Courses")
    var selections []StudentCourse
    for rows.Next() {
        var s StudentCourse
        rows.Scan(&s.Sno, &s.Sname, &s.Gender, &s.Age, &s.Cno, &s.Cname, &s.Credit)
        selections = append(selections, s)
    }
    fmt.Println(len(selections))
    fmt.Println(selections)
}

示例的运行结果如下:

9
[{1001 张三 M 20 1 数据库 4} {1001 张三 M 20 2 数学 2} {1001 张三 M 20 3 英语 3} {1002 李四 F 18 1 数据库 4} {1002 李四 F 18 2 数学 2} {1002 李四 F 18 3 英语 3} {1003 王五 M 19 1 数据库 4} {1003 王五 M 19 2 数学 2} {1003 王五 M 19 3 英语 3}]

我们看到对Students和Courses两个关系(表)进行笛卡尔积运算后,结果包含了Students中的每个元组与Courses中的每个元组进行组合的结果(3×3=9个)。

需要注意的是,由于笛卡尔积可能导致非常大的结果集,因此在实际使用中应谨慎使用,并且通常需要与其他运算符和条件结合使用,以限制结果的大小和提高查询效率。通常我们会用连接来达到这些目的。

4.9 连接(Join)

连接(Join)运算(⋈)是从两个关系的笛卡儿积中选取属性间满足一定条件的元组形成一个新的关系,即将笛卡尔积和选择(selection)运算合并达到一个操作中。从这个角度来看,笛卡尔积可以视为一种无条件的连接

连接代数运算符是关系代数中很有用的关系代数运算符,也是日常经常使用的运算符,它有很多种不同的子类别,下面我们分别看看各种子类型的语义、SQL语句以及对应的Go代码示例。

4.9.1 等值连接(Equijoin)

等值连接是通过比较两个关系(表)之间的属性值是否相等来进行连接的操作。连接条件使用等号(=)来比较属性值的相等性。

我们直接看Go示例:

// relational-algebra-examples/query/main.go

func dumpOperationResult(operation string, rows *sql.Rows) {
    cols, _ := rows.Columns()

    w := tabwriter.NewWriter(os.Stdout, 0, 2, 1, ' ', 0)
    defer w.Flush()
    w.Write([]byte(strings.Join(cols, "\t")))
    w.Write([]byte("\n"))

    row := make([][]byte, len(cols))
    rowPtr := make([]any, len(cols))
    for i := range row {
        rowPtr[i] = &row[i]
    }

    fmt.Printf("\n%s operation:\n", operation)
    for rows.Next() {
        rows.Scan(rowPtr...)
        w.Write(bytes.Join(row, []byte("\t")))
        w.Write([]byte("\n"))
    }
}

func doEquijoin(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM CourseSelection2022 JOIN Students ON CourseSelection2022.Sno = Students.Sno")
    dumpOperationResult("Equijoin", rows)
}

这个示例使用等值连接将CourseSelection2022表和Students表连接起来,连接条件是CourseSelection2022.Sno = Students.Sno,即学生编号相等,返回的结果将包含CourseSelection2022和Students两个表中满足连接条件的元组。

我们看看程序运行的输出结果:

Equijoin operation:
Sno  Cno Score Sno  Sname Gender Age
1001 1   85    1001 张三    M      20
1001 2   80    1001 张三    M      20
1002 1   83    1002 李四    F      18
1003 1   76    1003 王五    M      19

在这个结果中,我们看到一个“奇怪”的情况,那就是出现了两个Sno属性。在等值连接中,如果连接的两个表中存在相同名称的属性(例如这里两个表中都有名为”Sno”的属性),那么在连接结果中会出现两个相同名称的属性。

这是因为等值连接会将两个表中具有相同连接条件的属性进行匹配,并将匹配成功的元组进行组合。由于两个表中都有名为”Sno”的属性,因此连接结果中会保留这两个属性,以显示连接操作前后的对应关系。

为了区分来自不同表的相同属性名,通常在连接结果中会使用表别名或表名作为前缀,以区分它们的来源。这样可以确保结果中的属性名称是唯一的,避免歧义。 例如,如果在等值连接中连接了名为”CourseSelection2022″的表和名为”Students”的表,并且两个表中都有名为”Sno”的属性,那么连接结果中可能会出现类似于”CourseSelection2022.Sno”和”Students.Sno”的属性名称,以明确它们的来源。

需要注意的是,数据库管理系统的具体实现和查询工具的设置可能会影响连接结果中属性的显示方式,但通常会采用类似的方式来区分相同属性名的来源。

4.9.2 自然连接(Natural Join)

自然连接是基于两个表中具有相同属性名的属性进行连接的操作,重点在于它会自动匹配具有相同属性名的属性,并根据这些属性的相等性进行连接,而无需手工指定

我们来看自然连接的Go示例:

// relational-algebra-examples/query/main.go

func doNaturaljoin(db *sql.DB) {
    rows, _ := db.Query("SELECT * FROM CourseSelection2022 NATURAL JOIN Students")
    dumpOperationResult("Naturaljoin", rows)
}

这个示例使用自然连接将CourseSelection2022表和Students表连接起来,自然连接会自动基于两个表中所有具有相同属性名的属性进行连接,返回的结果将包含CourseSelection2022和Students两个表中所有满足连接条件的元组,并自动消除重复属性,这是与等值连接的一个明显的区别。

我们看看程序运行的输出结果:

Naturaljoin operation:
Sno  Cno Score Sname Gender Age
1001 1   85    张三    M      20
1001 2   80    张三    M      20
1002 1   83    李四    F      18
1003 1   76    王五    M      19

如果两个表(比如R1和R2)有一个以上的属性名相同,比如2个(比如:A1和A2),那就会自动针对这两个属性名(一起)在两个表中进行等值连接:只有R2.A1 = R1.A1且R2.A2 = R1.A2时,才将元组连接并放入结果关系中。

4.9.3 θ连接(Theta Join)

θ连接是一种通用的连接操作,它使用比等号更一般化的连接条件进行连接。连接条件可以使用除了等号之外的比较运算符(如大于、小于、不等于等)来比较两个表之间的属性。

我们来看θ连接的Go示例:

// relational-algebra-examples/query/main.go

func doThetajoin(db *sql.DB) {
    rows, _ := db.Query(`SELECT *
FROM CourseSelection2022
JOIN Students ON CourseSelection2022.Sno > Students.Sno`)
    dumpOperationResult("Thetajoin", rows)
}

这个示例使用Join将CourseSelection2022表和Students表连接起来,连接条件是CourseSelection2022.Sno > Students.Sno,即学生编号大于学生表中的学生编号,返回的结果将包含CourseSelection2022和`Students两个表中满足连接条件的元组。

Thetajoin operation:
Sno  Cno Score Sno  Sname Gender Age
1002 1   83    1001 张三    M      20
1003 1   76    1001 张三    M      20
1003 1   76    1002 李四    F      18

这个结果的生成过程大致如下:

  • 先看CourseSelection2022表的第一个元组,其Sno为1001,该Sno不大于Students表中的任一个Sno;
  • 再看CourseSelection2022表的第二个元组,其Sno为1002,该Sno仅大于Students表中的Sno为1001的那一个元组,于是将CourseSelection2022表的第二个元组和Students表中第一个元组连接起来作为结果表中的第一个元组;
  • 最后看CourseSelection2022表的第三个元组,其Sno为1003,该Sno大于Students表中的Sno为1001和1002的元组,于是将CourseSelection2022表的第三个元组分别和Students表中第一个和第二个元组连接起来作为结果表中的第二个和第三个元组。

4.9.4 半连接(Semi Join)

半连接是一种特殊的连接操作,它返回满足连接条件的左侧关系中的元组,并且只返回右侧关系中与之匹配的属性。半连接通常用于判断两个关系中是否存在匹配的元组,而不需要返回右侧关系的详细信息。

我们来看半连接的Go示例:

// relational-algebra-examples/query/main.go

func doSemijoin(db *sql.DB) {
    rows, _ := db.Query(`SELECT *
FROM Students
WHERE EXISTS (
    SELECT *
    FROM CourseSelection2022
    WHERE Students.Sno = CourseSelection2022.Sno
)`)
    dumpOperationResult("Semijoin", rows)
}

这个示例使用半连接操作,以Students表为左侧关系,CourseSelection2022表为右侧关系。它使用子查询来判断左侧关系中是否存在满足连接条件的元组,即Students.Sno = CourseSelection2022.Sno。它返回的结果将只包含满足连接条件的Students表中的元组。

下面是程序输出的结果:

Semijoin operation:
Sno  Sname Gender Age
1001 张三    M      20
1002 李四    F      18
1003 王五    M      19

半连接返回的结果关系中只包含左关系中的行,其中每一行只返回一次,即使在右关系中有多个匹配项。

4.9.5 反连接(Anti Join)

反连接是半连接的补集操作,它返回左侧关系中不存在满足连接条件的元组。反连接通常用于查找在左侧关系中存在而在右侧关系中不存在的元组。

我们来看反连接的Go示例:

// relational-algebra-examples/query/main.go

func doAntijoin(db *sql.DB) {
    rows, _ := db.Query(`SELECT *
FROM Students
WHERE NOT EXISTS (
    SELECT *
    FROM CourseSelection2022
    WHERE Students.Sno = CourseSelection2022.Sno
)`)
    dumpOperationResult("Antijoin", rows)
}

这个示例使用反连接操作,以Students表为左侧关系,CourseSelection2022表为右侧关系,并使用NOT EXISTS子查询来判断左侧关系中不存在满足连接条件的元组,即Students.Sno = CourseSelection2022.Sno。返回的结果将只包含左侧关系Students表中不存在连接条件的元组。

Antijoin operation:
Sno Sname Gender Age

我们看到输出的元组集合为空。

4.9.6 左(外)连接(Left Outer Join)

左外连接是将左侧关系中的所有元组与满足连接条件的右侧关系中的元组进行连接,并返回所有左侧关系的元组。如果右侧关系中没有与左侧关系匹配的元组,对应的属性值将为NULL。

我们来看左(外)连接的Go示例:

// relational-algebra-examples/query/main.go

func doLeftjoin(db *sql.DB) {
    rows, _ := db.Query(`SELECT *
FROM Students
LEFT JOIN CourseSelection2022 ON Students.Sno = CourseSelection2022.Sno`)
    dumpOperationResult("Leftjoin", rows)
}

这个示例使用左外连接将Students表和CourseSelection2022表连接起来,其连接条件是Students.Sno = CourseSelection2022.Sno,即学生编号相等。示例的返回结果将包含Students表中的所有元组,并将满足连接条件的CourseSelection2022表中的元组加入结果中。如果没有匹配的元组,右侧关系中的属性值将为NULL。
`
下面是程序输出的结果:

Leftjoin operation:
Sno  Sname Gender Age Sno  Cno Score
1001 张三    M      20  1001 1   85
1001 张三    M      20  1001 2   80
1002 李四    F      18  1002 1   83
1003 王五    M      19  1003 1   76

4.9.7 右(外)连接(Right Outer Join)

右外连接是将右侧关系中的所有元组与满足连接条件的左侧关系中的元组进行连接,并返回所有右侧关系的元组。如果左侧关系中没有与右侧关系匹配的元组,对应的属性值将为NULL。

我们来看右(外)连接的Go示例:

// relational-algebra-examples/query/main.go

func doRightjoin(db *sql.DB) {
    rows, _ := db.Query(`SELECT *
FROM Students
RIGHT JOIN CourseSelection2022 ON Students.Sno = CourseSelection2022.Sno`)
    dumpOperationResult("Rightjoin", rows)
}

这个示例使用右外连接将Students表和CourseSelection2022表连接起来,它的连接条件是Students.Sno = CourseSelection2022.Sno,即学生编号相等。返回的结果将包含CourseSelection2022表中的所有元组,并将满足连接条件的Students表中的元组加入结果中。如果没有匹配的元组,左侧关系中的属性值将为NULL。

下面是程序输出的结果:

Rightjoin operation:
Sno  Sname Gender Age Sno  Cno Score
1001 张三    M      20  1001 1   85
1001 张三    M      20  1001 2   80
1002 李四    F      18  1002 1   83
1003 王五    M      19  1003 1   76

4.9.8 全连接(Full Outer Join)

全连接是将左侧关系和右侧关系中的所有元组进行连接,并返回所有满足连接条件的元组。如果左侧关系或右侧关系中没有与对方匹配的元组,对应的属性值将为NULL。

我们来看全连接的Go示例:

// relational-algebra-examples/query/main.go

func doFulljoin(db *sql.DB) {
    rows, _ := db.Query(`SELECT *
FROM Students
FULL JOIN CourseSelection2022 ON Students.Sno = CourseSelection2022.Sno`)
    dumpOperationResult("Fulljoin", rows)
}

这个示例使用全连接将Students表和CourseSelection2022表连接起来,连接条件是Students.Sno = CourseSelection2022.Sno,即学生编号相等。示例返回的结果将包含Students表和CourseSelection2022表中的所有元组,并将满足连接条件的元组进行组合。如果没有匹配的元组,对应关系中的属性值将为NULL。

下面是程序输出的结果:

Fulljoin operation:
Sno  Sname Gender Age Sno  Cno Score
1001 张三    M      20  1001 1   85
1001 张三    M      20  1001 2   80
1002 李四    F      18  1002 1   83
1003 王五    M      19  1003 1   76

以上就是本文要介绍的连接类型,这些连接类型提供了在关系数据库中操作和组合表数据的灵活性,可以根据特定的需求选择合适的连接方式来获取所需的结果。

5. 小结

本文系统地介绍和讲解了关系数据库中的关系代数运算,包括选择、投影、连接、交、并、积等,以及关系代数的SQL实现,并给出了Go语言示例。

关系模型是关系数据库的理论基础,关系代数通过对关系的运算来表达查询,因此关系代数也构成了SQL查询语言的理论基础。理解关系代数与SQL的对应关系,可以更好地使用SQL语言操作关系型数据库。

本文算是关系数据库的入门文章,既能让数据库初学者快速掌握关系代数,也能让有基础的读者回顾并深入理解概念内涵。通过阅读学习,能帮助读者把关系代数运用到实际数据库应用中,解决查询优化等问题。

本文涉及的源码可以在这里下载。

注:由于环境所限,本文所有示例均是在sqlite3上进行的。

6. 参考资料


“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2023年,Gopher部落将进一步聚焦于如何编写雅、地道、可读、可测试的Go代码,关注代码质量并深入理解Go核心技术,并继续加强与星友的互动。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Cocos2d-x屏幕适配之Sprite绘制原理

手机(智能终端)游戏绝大多数为全屏(Full Screen)显示,这样开发人员在制作游戏时势必要考虑不同手机(智能终端)屏幕大小、宽高比的不同给游戏画面带来的影响,并且要将这种影响降低到最 小,努力使用不同终端的游戏玩家拥有几乎相同的游戏画面体验。为此各种游戏引擎在屏幕适配方面都给出了自己的方案,Cocos2d-x也不例外。 在Cocos2d-x官网Wiki上特地撰写了一篇讲解Cocos2d-x多屏幕适配原理的文章“Detailed explanation of Cocos2d-x Multi-resolution adaptation”。

这里我们以Cocos2d-x引擎(基于2.2.2版本)自带的Sample项目HelloCpp(cocos2d-x-2.2.2/samples/Cpp/HelloCpp)为例,直观的看看这个方案带来的好 处。首先,我们对HelloCpp项目做些许改造:
    – 注释掉AppDelegate.cpp中applicationDidFinishLaunching下的pEGLView->setDesignResolutionSize(designResolutionSize.width, designResolutionSize.height, kResolutionNoBorder);
    – 仅使用Resource/iphone下的资源,即仅searchPath.push_back(smallResource.directory); 这里我们有一张480×320分辨率大小PNG文件。
    – 通过改变proj.linux/main.cpp中的eglView->setFrameSize(960, 640);来改变屏幕参数。(用linux工程模拟甚为方便,编译和运行占用资源小,极为迅捷,效果与Android平台是等 效的)

我们对比一下以下三种条件下的游戏Demo显示结果:
    1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。
    3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320);

如我们所料,我们得到三个截然不同的结果。

第一种情况,我们所得到的游戏屏幕截图如下:

第二种情况,我们所得到的游戏屏幕截图如下:

第三种情况,我们所得到的游戏屏幕截图如下:

第一种情况是最理想的情况,屏幕大小与背景图片大小相同,如我们所愿,屏幕与背景图片吻合的天衣无缝。
第二种情况显然是模拟我们初次遇到问题的场景。屏幕Size扩大为原先的二倍,在资源没有变化的情况下,我们发现480×320大小的背景图片没 有铺满屏幕,仅仅是居中显示,并在四周露出较多”黑边“,这显然不是我们想要的。
第三种情况,也就是我们按照官方屏幕适配方案调整后得到的结果,在资源依旧不变的情况下,我们得到了相对令人满意的结果:背景图片恰如其分的铺满 整个屏幕,比例正确。这样我们用一套资源就可以同时适配两个屏幕了:480×320、960×640。这两种终端的玩家至少不会对我们的游戏心生 抱怨之情^_^。

当然在遇到第二种情况的时候,你也大可再准备一套新资源,比如一张960×640的背景图片。在480×320手机上,使用480×320的图 片;在960×640的手机上,使用960×640的背景图片。但这种方法的弊端至少有三:
    – 包大了:游戏的安装包Size急剧变大。
    – 活儿多了:因适配屏幕种类太多而制作大量的图片。
    – 新屏幕出来咋办:如果某个厂家突然于某天出品一款手机,其分辨率与以往市面上的所有手机均不同,那你的游戏因没有对应的资源,肯定无法很好适配该手机,导 致较差用户体验。

为此,适配屏幕唯一的出路似乎只有按照官方推荐的方案进行了,当然适当结合有限种类的资源也许可以更好的提升游戏体验。

如果仅仅从游戏制作角度来看,我们找到了可以适配屏幕的方法就可以了,没有必要刨根问底。甚至当有人问起来:为何 setDesignResolutionSize后,背景图片就可以充满屏幕了呢?我们可以回答:“引擎对精灵进行了缩放,就是这样”。但对于上 面的背景精灵来说,真的是我们理解的普通意义上的“精灵缩放(Scale)吗?本着“知其然,也要知其所以然”的精神,这里对引擎如何对 Sprite进行绘制进行了一番研究,我还真发现了一些与我之前理解差异较大的“深奥”原理,这里与大家一起分享一下。

一、绘制参数初始化

我们还是从代码开始,了解一下引擎绘制参数的初始化工作是如何做的、在哪里做的,为后续的分析做些铺垫。这里以Cocos2d-x 2.2.2 Android平台为例。关于Cocos2d-x 2.2.2 Android平台的引擎粗线条启动流程分析,可以参考《Hello,Cocos2d-x》这篇文章。看完这篇文章,你就会知道我们这次应该从Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit开 始。

// samples/Cpp/HelloCpp/proj.android/jni/hellocpp/main.cpp
void Java_org_cocos2dx_lib_Cocos2dxRenderer_nativeInit(
               JNIEnv*  env, jobject thiz, jint w, jint h)
{
    if (!CCDirector::sharedDirector()->getOpenGLView())
    {
        CCEGLView *view = CCEGLView::sharedOpenGLView();
        view->setFrameSize(w, h);

        AppDelegate *pAppDelegate = new AppDelegate();
        CCApplication::sharedApplication()->run();
    }
    … …
}

这里是引擎部分初始化的起点:CCDirector和CCEGLView先后完成创建与初始化。接下来我们分别看一下这两个过程,我们主要关 注与绘制参数设置相关的内容:

bool CCDirector::init(void)
{
    setDefaultValues();

    … …
    m_obWinSizeInPoints = CCSizeZero;

    m_pobOpenGLView = NULL;

    m_fContentScaleFactor = 1.0f;
    … …
    return true;
}

void CCDirector::setDefaultValues(void)
{
    CCConfiguration *conf =
     CCConfiguration::sharedConfiguration();
    … …
    // GL projection
    const char *projection =
        conf->getCString("cocos2d.x.gl.projection",
                         "3d");
    if( strcmp(projection, "3d") == 0 )
        m_eProjection = kCCDirectorProjection3D;
    … …
}

由于conf中没有配置“cocos2d.x.gl.projection”,因此projection使用了 getCString传入的默认值:"3d",m_eProjection则被赋值为kCCDirectorProjection3D。

CCEGLView的创建更为简单:

CCEGLView::CCEGLView()
{
    initExtensions();
}

但背后真正发挥关键作用的是其父类CCEGLViewProtocol。

CCEGLViewProtocol::CCEGLViewProtocol()
: m_pDelegate(NULL)
, m_fScaleX(1.0f)
, m_fScaleY(1.0f)
, m_eResolutionPolicy(kResolutionUnKnown)
{
}

这里我们看到了三个重要的字段:m_fScaleX、m_fScaleY以及m_eResolutionPolicy,这三个字段对于后续屏 幕适配起到至关重要的作用。

nativeInit中的view->SetFrameSize(w, h)用于设置的屏幕物理分辨率,如果你的手机是960×640分辨率的,那FrameSize就是960×640。

void CCEGLViewProtocol::setFrameSize(float width,
                                     float height)
{
    m_obDesignResolutionSize
      = m_obScreenSize
      = CCSizeMake(width, height);
}
初始情况下,CCEGLViewProtocol将“设计分辨率”m_obDesignResolutionSize也设置为与 FrameSize or m_obScreenSize同等大小。

我们回到游戏逻辑层代码AppDelegate.cpp,我们知道游戏逻辑的入口在这里,最初的参数初始化是在为Director设置 GLView实例时进行的:

bool AppDelegate::applicationDidFinishLaunching() {
    // initialize director
    CCDirector* pDirector = CCDirector::sharedDirector();
    CCEGLView* pEGLView = CCEGLView::sharedOpenGLView();

    pDirector->setOpenGLView(pEGLView);
    CCSize frameSize = pEGLView->getFrameSize();
    … …
}

void CCDirector::setOpenGLView(CCEGLView *pobOpenGLView)
{
        m_pobOpenGLView = pobOpenGLView;

        // set size
        m_obWinSizeInPoints =
           m_pobOpenGLView->getDesignResolutionSize();
        … …

        if (m_pobOpenGLView)
        {
            setGLDefaultValues();
        }

        CHECK_GL_ERROR_DEBUG();
        … …
    }
}

由于尚未调用setDesignResolutionSize,因此m_obWinSizeInPoints的值与FrameSize大小相 同。

setGLDefaultValues最为关键,这是我们第一次遇到该函数,该方法用于初始化一些OpenGL的参数,建立好后续 OpenGL操作时所需要的各种数据结构。

void CCDirector::setGLDefaultValues(void)
{
    … …
    setAlphaBlending(true);
    setDepthTest(false);
    setProjection(m_eProjection);
    // set other opengl default values
    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
}

glClearColor(0.0f, 0.0f, 0.0f, 1.0f);设置初始颜色为黑色,alpha为1.0f,即完全不透明。setProjection是实际上绘制参数设置的核心。

void CCDirector::setProjection(ccDirectorProjection kProjection)
{
    CCSize size = m_obWinSizeInPoints;

    setViewport();
   
    switch (kProjection)
    {
    case kCCDirectorProjection3D:
        {
            float zeye = this->getZEye();

            kmMat4 matrixPerspective, matrixLookup;

            kmGLMatrixMode(KM_GL_PROJECTION);
            kmGLLoadIdentity();

            … …

            // issue #1334
            kmMat4PerspectiveProjection( &matrixPerspective,
                   60,
                  (GLfloat)size.width/size.height,
                   0.1f, zeye*2);

            kmGLMultMatrix(&matrixPerspective);

            kmGLMatrixMode(KM_GL_MODELVIEW);
            kmGLLoadIdentity();
            kmVec3 eye, center, up;
            kmVec3Fill( &eye, size.width/2,
                   size.height/2, zeye );
            kmVec3Fill( &center, size.width/2,
                   size.height/2, 0.0f );
            kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
            kmMat4LookAt(&matrixLookup, &eye,
                         &center, &up);
            kmGLMultMatrix(&matrixLookup);
        }
        break;
        … …
    }

    m_eProjection = kProjection;
    ccSetProjectionMatrixDirty();
}

由于前面m_eProjection已经被赋值为kCCDirectorProjection3D,因此我们只分析 kCCDirectorProjection3D这个case分支。该函数大致进行设置的顺序是:设置视口变换(ViewPort)、设置投影变换矩阵和 设置模型视图变换矩阵。我们分别来看:

 * 设置视口(ViewPort)

void CCDirector::setViewport()
{
    if (m_pobOpenGLView)
    {
        m_pobOpenGLView->setViewPortInPoints(0, 0,
              m_obWinSizeInPoints.width,
              m_obWinSizeInPoints.height);
    }
}

void CCEGLViewProtocol::setViewPortInPoints(float x ,
                     float y , float w , float h)
{
    glViewport((GLint)(x * m_fScaleX
               + m_obViewPortRect.origin.x),
               (GLint)(y * m_fScaleY
               + m_obViewPortRect.origin.y),
               (GLsizei)(w * m_fScaleX),
               (GLsizei)(h * m_fScaleY));
}

这是我们遇到的第一个OpenGL概念:设置视口变换,关于视口变换究竟起到什么作用,后续会细说。

 * 设置“投影变换”矩阵参数

 kmMat4PerspectiveProjection( &matrixPerspective, 60,
        (GLfloat)size.width/size.height, 0.1f, zeye*2);
 kmGLMultMatrix(&matrixPerspective);

 * 设置“模型视图变换”矩阵参数

 kmVec3 eye, center, up;
 kmVec3Fill( &eye, size.width/2,
             size.height/2, zeye );
 kmVec3Fill( &center, size.width/2,
             size.height/2, 0.0f );
 kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
 kmMat4LookAt(&matrixLookup, &eye,
             &center, &up);

至此,引擎的绘制参数初始化设置就OK了,在你调用setDesignResolutionSize之前,这些参数不会被改变。

二、kazmath

Cocos2d-x引擎最底层采用OpenGL ES 2.0进行图形绘制,这样要想搞清楚前面的问题缘由,对OpenGL那一套技术体系至少要有一些直观认识才行。在这之前,我们还要先了解一些 Cocos2d-x深度使用的kazmath库。根据《Cocos2d-x高级开发教程》书 中说: “因为在Cocos2d-x 2.0采用的OpenGL ES 2.0中,而那些OpenGL ES 1.0函数已经不可使用了。但OpenGL ES 2.0已经放弃了固定的渲染流水线,取而代之的是自定义的各种着色器,在这种情况下变换操作通常需要由开发者来维护。所幸引擎也引入了一套第三方库 Kazmath,它使得我们几乎可以按照原来OpenGL ES 1.0所采用的方式进行开发”。

至此,我们大致知道了Kazmath库是用来辅助我们按照OpenGL ES 1.0的方式管理变换矩阵以及做变换操作的,接下来我们一起来看看kazmath库的结构吧:

//cocos2d-x-2.2.2/cocos2dx/kazmath/src/GL/matrix.c

km_mat4_stack modelview_matrix_stack;
km_mat4_stack projection_matrix_stack;
km_mat4_stack texture_matrix_stack;
km_mat4_stack* current_stack = NULL;
static unsigned char initialized = 0;

以上是Cocos2d-x整个引擎生命周期内会用到的与opengl变换矩阵相关的一些全局变量。

kazmath声明了三个变换矩阵的栈,modelview_matrix_stack(模型视图矩阵栈)、 projection_matrix_stack(投影矩阵栈)以及texture_matrix_stack(纹理矩阵栈)。不过Cocos2d-x引 擎只用到了前两个变化矩阵栈。current_stack指向当前所使用的那个变换矩阵栈。

这些栈的初始化在lazyInitialize中:

void lazyInitialize()
{

    if (!initialized) {
        kmMat4 identity; //Temporary identity matrix

        //Initialize all 3 stacks
        //modelview_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&modelview_matrix_stack);

        //projection_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&projection_matrix_stack);

        //texture_matrix_stack =
            (km_mat4_stack*) malloc(sizeof(km_mat4_stack));
        km_mat4_stack_initialize(&texture_matrix_stack);

        current_stack = &modelview_matrix_stack;
        initialized = 1;

        kmMat4Identity(&identity);

        //Make sure that each stack has the identity matrix
        km_mat4_stack_push(&modelview_matrix_stack, &identity);
        km_mat4_stack_push(&projection_matrix_stack, &identity);
        km_mat4_stack_push(&texture_matrix_stack, &identity);
    }
}

kmMat4Identify用于初始化“单位矩阵(Indentify Matrix)”,所谓"单位矩阵",指的是对脚线上元素都为1的矩阵。从kmMat4Identify的实现,我们也可以看出这一点:

kmMat4* const kmMat4Identity(kmMat4* pOut)
{
    memset(pOut->mat, 0, sizeof(float) * 16);
    pOut->mat[0] = pOut->mat[5]
     = pOut->mat[10]
     = pOut->mat[15] = 1.0f;

    return pOut;
}

最后,lazyInitialize函数将单位矩阵分别圧入(km_mat4_stack_push)不同的matrix stack。

再回顾一下CCDirector::setProjection,该函数通过kazmath先后设置了 projection_matrix_stack和modelview_matrix_stack的top元素。

   kmGLMatrixMode(KM_GL_PROJECTION);
   kmGLLoadIdentity();
   kmMat4PerspectiveProjection( &matrixPerspective, 60,
     (GLfloat)size.width/size.height, 0.1f, zeye*2);
   kmGLMultMatrix(&matrixPerspective);
  
   kmGLMatrixMode(KM_GL_MODELVIEW);
   kmGLLoadIdentity();
   kmVec3 eye, center, up;
   kmVec3Fill( &eye, size.width/2,
               size.height/2, zeye );
   kmVec3Fill( &center, size.width/2,
               size.height/2, 0.0f );
   kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
   kmMat4LookAt(&matrixLookup, &eye,
               &center, &up);
   kmGLMultMatrix(&matrixLookup);

三、精灵绘制

由《Hello,Cocos2d-x》一文我们知道,一旦引擎初始化完毕,就开始了每帧图像的绘制工作,Render Thread在一个“死循环”中反复调用CCDirector的drawScene方法 (CCDisplayLinkDirector::mainLoop中调用了drawScene):

void CCDirector::drawScene(void)
{
    … …
    glClear(GL_COLOR_BUFFER_BIT
           | GL_DEPTH_BUFFER_BIT);
    … …
    kmGLPushMatrix();

    // draw the scene
    if (m_pRunningScene)
    {
        m_pRunningScene->visit();
    }
    … …
    kmGLPopMatrix();
    … …
}

Cocos2d-x采用“渲染树”的方式进行绘制,即先从场景(Scene)的顶层根节点开始,深度优先的递归绘制Child Node。而整个绘制的顶层节点是CCScene。绘制从m_pRunningScene->visit()真正开始。visit是Scene、 Layer、Sprite的共同父类CCNode实现的方法:

void CCNode::visit()
{
    if (!m_bVisible)
    {
        return;
    }
    kmGLPushMatrix();
    … …
    this->transform();
    … …
   
    if(m_pChildren &&
       m_pChildren->count() > 0)
    {
        sortAllChildren();
        // draw children zOrder < 0
        … ..
        // self draw
        this->draw();

        // draw other children nodes
        … …
    } else {
        this->draw();
    }
    … …
    kmGLPopMatrix();
}
   
Visit大致做了这么几件事:
    – 向当前OpenGL变换矩阵栈Push元素
    – 用当前OpenGL变换矩阵栈栈顶元素的变换参数做节点变换
    – 递归绘制zOrder < 0 的子节点
    – 绘制自己
    – 递归绘制其他子节点
    – 从当前OpenGL变换矩阵栈Pop元素

如果你想知道为什么父节点缩放(Scale)、旋转(Rotate)、扭曲(Skew)后,子节点也会跟着父节点同样缩放(Scale)、旋 转(Rotate)、扭曲?其原理就在这里的transform方法中:

void CCNode::transform()
{
    kmMat4 transfrom4x4;

    // Convert 3×3 into 4×4 matrix
    CCAffineTransform tmpAffine
       = this->nodeToParentTransform();
    CGAffineToGL(&tmpAffine,
                 transfrom4x4.mat);

    // Update Z vertex manually
    transfrom4x4.mat[14] = m_fVertexZ;

    kmGLMultMatrix( &transfrom4x4 );
    … …
}

在进入tranform以前,Cocos2d-x做了啥?对了,kmGLPushMatrix():

void kmGLPushMatrix(void)
{
    kmMat4 top;

    lazyInitialize();

    //Duplicate the top of the stack (i.e the current matrix)
    kmMat4Assign(&top, current_stack->top);
    km_mat4_stack_push(current_stack, &top);
}

在引擎初始化后,我们的current_stack是模型视图矩阵栈modelview_matrix_stack。所有设置的初始参数都保 存在该栈的栈顶元素中。在每次Node绘制前,Node都会创建自己的变换矩阵,但这个矩阵不是凭空创造的,从kmGLPushMatrix 可以看出,在当前Node将新创建的矩阵元素圧栈前,它复制了原栈顶元素,也就携带有父节点所有的初始变换信息,也就是说在 km_mat4_stack_push后,栈顶放置的元素其实是原栈顶元素的复制品,而后续所有操作都是基于这个复制品的。这样一来,如果父 节点做了缩放或旋转或扭曲,那这些信息都会作为初始信息作为子节点变换的基础,后续子节点自身的变换参数也都是在这个基础上做出的,最终的矩 阵是transform方法中的kmGLMultMatrix后得出的。真正的矩阵变换计算都在nodeToParentTransform 中,不过要想看懂这个函数,需要对OpenGL有更深入的了解才行,这里略过^_^。

真正绘制Node的方法是CCNode::draw的override方法。CCNode::draw是一个空函数,各个子类 override该方法进行各自的绘制。以CCSprite::draw为例:

void CCSprite::draw(void)
{
    CC_NODE_DRAW_SETUP();

    ccGLBlendFunc( m_sBlendFunc.src, m_sBlendFunc.dst );

    ccGLBindTexture2D( m_pobTexture->getName() );
    ccGLEnableVertexAttribs( kCCVertexAttribFlag_PosColorTex );

#define kQuadSize sizeof(m_sQuad.bl)
    long offset = (long)&m_sQuad;

    // vertex
    int diff = offsetof( ccV3F_C4B_T2F, vertices);
    glVertexAttribPointer(kCCVertexAttrib_Position, 3,
     GL_FLOAT, GL_FALSE, kQuadSize, (void*) (offset + diff));

    // texCoods
    diff = offsetof( ccV3F_C4B_T2F, texCoords);
    glVertexAttribPointer(kCCVertexAttrib_TexCoords, 2,
      GL_FLOAT, GL_FALSE, kQuadSize, (void*)(offset + diff));

    // color
    diff = offsetof( ccV3F_C4B_T2F, colors);
    glVertexAttribPointer(kCCVertexAttrib_Color, 4,
           GL_UNSIGNED_BYTE, GL_TRUE,
           kQuadSize, (void*)(offset + diff));

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    … …
}

这里的draw是一个典型的OpenGL绘制工序。CC_NODE_DRAW_SETUP()将之前的经过若干准备而得到的最终各类变换矩阵 整合并传给OpenGL:

/** @def CC_NODE_DRAW_SETUP
 Helpful macro that setups the GL server state,
 the correct GL program and sets the Model View
 Projection matrix
 @since v2.0
 */
#define CC_NODE_DRAW_SETUP() \
do { \
    ccGLEnable(m_eGLServerState); \
    CCAssert(getShaderProgram(), "No shader program set for this node"); \
    { \
        getShaderProgram()->use(); \
        getShaderProgram()->setUniformsForBuiltins(); \
    } \
} while(0)

void CCGLProgram::setUniformsForBuiltins()
{
    kmMat4 matrixP;
    kmMat4 matrixMV;
    kmMat4 matrixMVP;

    kmGLGetMatrix(KM_GL_PROJECTION, &matrixP);
    kmGLGetMatrix(KM_GL_MODELVIEW, &matrixMV);

    kmMat4Multiply(&matrixMVP, &matrixP, &matrixMV);

    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformPMatrix],
                                    matrixP.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVMatrix],
                                    matrixMV.mat, 1);
    setUniformLocationWithMatrix4fv(m_uUniforms[kCCUniformMVPMatrix],
                                    matrixMVP.mat, 1);
    … …
}

经过计算顶点、绑定纹理等步骤后,最终由glDrawArrays完成Node绘制。

四、m_fScaleX和m_fScaleY都是1.0,背景精灵为何被放大?

根据上面的分析,我们了解到“子节点将跟随父节点的缩放而缩放”。据此,我们来分析一下前面提到的屏幕适配例子中的第三种情况,即屏幕大小为 960×640,按照Cocos2d-x屏幕适配指南Wiki中的做法,调用 pEGLView->setDesignResolutionSize(480, 320)。在该情况中,我们得到的结果是480×320大小的背景图片充满了大小为960×640的屏幕窗口,这给我们的直观印象就是背景图片被放大了一 倍。下面我们就尝试用上面的分析来解释一下这个现象。

在这个例子中,渲染树结构如下:
   CCScene
        – CCLayer
            – CCSprite – 背景图精灵

按照之前的理论,背景图精灵自身或父类应该有缩放的设置,比如m_fScaleX = 2.0之类的设置,于是我在代码中输出了Scene、Layer以及Sprite的m_fScaleX和m_fScaleY值。但出乎预料的是,这些 Node子类的两个轴向缩放值都保持了默认值,即1.0f。在代码里翻了半天,也的确没有找到改写Scene、Layer或Sprite Scale的地方。又一想:代码中调用了setDesignResolutionSize,这样CCEGLView的m_fScaleX = m_fScaleY = 2.0f,难道是CCEGLView的m_fScale传递给了CCScene等Node子类,但事实总是残酷的,代表这一联系的代码也始终未被我所找 到,看来继续纠结m_fScale的值设置是无法搞清楚真正原因,应该换换思路了。这里背景图的放大不应该是Node scale值设置的问题,也就是说关键环节不应该在绘制流程,而是在之前的OpenGL变换矩阵参数设置,看来不再深入学习点OpenGL知识,这个问题 就很难搞定了,于是开始翻看《OpenGL编程指南7th》(号称OpenGL红宝书)和《OpenGL超级宝典》(号称OpenGL蓝宝 书)。虽然我的阅读是粗粒度的,但还是收获到了一些答案。

五、OpenGL基础

OpenGL是帮助我们将三维世界的物体转换到二维屏幕上的一组接口。在新技术尚未出现之前,我们的屏幕永远是二维的,即便是现在的3D电影 也是双眼视角二维图像叠加的结果。我们知道“将大象装进冰箱总共分三 步”,将一个三维模型转换到二维屏幕上,OpenGL也规定了相对流水线般的步骤。

OpenGL三维图形的显示流程

三维图形显示流程中,涉及到OpenGL的一个重要操作,那就是“变换(Transformation)”,主要的变换包括模型视图变换 (model-view transformation)、投影变换(projection transformation)以及视口变换(ViewPort transformation)。我们经常用相机模拟来对比OpenGL解决这一问题的过程以及相关概念。

回顾一下我们自己用相机拍照的步骤吧。

第零步,选景。景就是所谓的三维模型或三维物体,或简称模型(Model),就是我们要显示到屏幕上的物体;
第一步,确定相机位置。让相机以一定的距离、高度、角度对准模型。在这里,相机的位置变换,对应OpenGL的“视图变换或叫视点变换 (View Transformation)”。在这一步里(对应上面图中的第二步),我们还可以调整三维物体的相对位置、角度与相机的距离,这就是模型变换 (Modeling Transformation),两种变换达成的效果是相同的,因此总称模型视图变换(Model-View Transformation)。
第二步,选镜头,并调焦。确定图像投影在胶片上的范围以及景深等。这一步叫投影变换(Projection Transformation)。
第三步,冲洗照片。拍摄好的图像放在底片上,但我们需要选择冲洗后最终是放在6寸相纸还是20寸相纸上,显然在不同大小相纸上,图像的显示效 果不同(比如大小)。这个过程叫视口变换(Viewport Transformation)。

三维空间的物体都是用三维坐标描述的,谈到坐标就离不开坐标系,OpenGL中的坐标系就有多种,我们最常用的就是世界坐标系。

世界坐标系是以屏幕中心为原点(0, 0, 0),你面对屏幕,你的右边是x正轴,上面是y正轴,屏幕指向你的为z正轴。无论如何变换,世界坐标系都不动。我们在Cocos2d-x中设置 初始参数时,参数的单位多为世界坐标系中的单位。

视点变换时会涉及到视点坐标系,但这个变换由opengl接口来负责,我们不用过多关心。

绘图坐标系(局部坐标系),当前绘图坐标系是绘制物体时的坐标系。程序刚初始化时,世界坐标系和当前绘图坐标系是重合的,当用 glTranslatef()等变换函数做移动和旋转时,都是改变的当前绘图坐标系,改变的位置都是当前绘图坐标系相对自己的x,y,z轴所做的 改变,改变以后,再绘图时,都是在当前绘图坐标系进行绘图,所有的函数参数也都是相对当前绘图坐标系来讲的。

屏幕坐标系,即终端屏幕上的坐标系,与世界坐标系有不同,它以屏幕左上角的点为原点,向右是x正轴,向下是y正轴,屏幕指向你的为z正轴。

注意视口(Viewport)的设置是以实际屏幕坐标定义了窗口中的区域,长度宽度都是以实际像素为单位。当然引擎在精灵绘图时用 的是绘图坐标系,我们理解原点在左下角即可。

六、Cocos2d-x各种变换矩阵的初始参数设置

前面说过,Cocos2d-x在CCDirector::setProjection中完成了对变换矩阵的初始参数设置,我们逐一来看看这些设置对模型映射后的二维图像有何影响,这也是理解篇头几个问题的关键环节。

  * 投影变换
   
    前面提到过,投影变换相当于调节相机镜头。OpenGL中提供了两种投影方式,一种是正射投影,另一种是透视投影。Cocos2d-x使用的是透视投影 (Perspective Projection)。透视投影是实际人们观察事物的真实反馈,即离视点近的物体大,离视点远的物体小,远到极点即为消失,成为灭点。Cocos2d- x使用的是kmMat4PerspectiveProjection,对应OpenGL中的gluPerspective,该方法创建一个对称透视视景体 (View Volumn),见下图:

gluPerspective的函数原型如下:void gluPerspective(GLdouble fovy,GLdouble aspect,GLdouble zNear, GLdouble zFar);

    参数fovy定义视野在X-Z平面的角度,范围是[0.0, 180.0],也就是上图中的“视角”;
    参数aspect是投影平面宽度与高度的比率;
    参数zNear和Far分别是近远裁剪面沿Z负轴到视点的距离,它们总为正值。
  
Cocos2d-x中是这么设置投影变换矩阵的:

  float zeye = this->getZEye();
  kmMat4PerspectiveProjection( &matrixPerspective, 60, (GLfloat)size.width/size.height, 0.1f, zeye*2);

  float CCDirector::getZEye(void)
  {
    return (m_obWinSizeInPoints.height / 1.1566f);
  }

从参数上来看,
    视角 = 60度
    宽高比 = 设计分辨率的宽高比,
    近平面 = 距离视点0.1f,几乎与视点重合
    远平面 = 距离视点zeye * 2距离。
    视点位置 = 设计分辨率.height / 1.1566f

投影是用来对模型进行截取的,只有在投影变换所建立的平头截体(Frustum,投影的近、远两个截面以及其他四个面构成的立体体)内的模型部分才会被最终映射和显示。我们用下面的图来直观了解一下各个参数在三维空间的概念吧。

显然引擎如此设置投影矩阵的参数是有考虑的:
首先就是投影平头截体的宽高比 = 设计分辨率的宽高比,这样设置使得一切符合设计分辨率宽高比的模型都可以被理想截取。
其次,视角60度,zEye的在Z轴正方向距离世界原点的距离 = (m_obWinSizeInPoints.height / 1.1566f),这里的1.1566f是怎么来的呢?我们沿着X轴负方向向zy平面投影,得到下图:

看这个图,让我想起了初中几何,通过60度的视角,我们可以推断由eye、XZ截断上平面与Y轴的交点、XZ截断下平面与Y轴的交点组成一个等边三角形, 现在我们已知在Zy平面投影中视点与原点的距离为m_obWinSizeInPoints.height / 1.1566f, 我们还知道夹角是60度,我们求一下投影在(z=0,XY平面)的截面高度h。

cos30 = (m_obWinSizeInPoints.height / 1.1566f)/ h
h = (m_obWinSizeInPoints.height / 1.1566f)/cos30 = m_obWinSizeInPoints.height;

我们计算出来的结果是 h = m_obWinSizeInPoints.height = 设计分辨率中的高度分量。这意味这什么呢?Cocos2d-x是2D游戏渲染引擎,针对该引擎的模型的z坐标都是0,因此模型实际上就在xy平面内,也就 是说eye与原点的距离恰好就是eye与模型的距离,而模型可显示区域的最大高度也就是h,即m_obWinSizeInPoints.height。这 个结论会在后续问题分析时发挥作用。

注意虽然这里知道eye在Z轴正方向距离世界原点的距离,但eye的(x, y)坐标在投影设置后依旧无法确认,我们需要在设置模型视图变换时得到eye的(x, y)坐标。

  * 视图变换

    kmGLMatrixMode(KM_GL_MODELVIEW);
    kmGLLoadIdentity();
    kmVec3 eye, center, up;
    kmVec3Fill( &eye, size.width/2, size.height/2, zeye );
    kmVec3Fill( &center, size.width/2, size.height/2, 0.0f );
    kmVec3Fill( &up, 0.0f, 1.0f, 0.0f);
    kmMat4LookAt(&matrixLookup, &eye, &center, &up);
    kmGLMultMatrix(&matrixLookup);

OpenGL原生的视图变换参数设置方法是gluLookAt,在kazmath中对应的方法为kmMat4LookAt。gluLookAt的函数原型是:

    void gluLookAt(GLdouble eyex, GLdouble exey, GLdouble eyez,
       GLdouble centrex, GLdouble centrey, GLdouble centrez,
       GLdouble upx, GLdouble upy, GLdouble upz);

eye的坐标(eyex, eyey, eyez), Cocos2d-x中是这么设置的kmVec3Fill( &eye, size.width/2, size.height/2, zeye )。可以看出eye在xy平面的投影恰好是以屏幕分辨率构成的矩形的中心。

centre坐标,表示的是视线方向,该方向矢量是由eye坐标、centre坐标共同构成的,由eye指向center。Cocos2d-x的设置 kmVec3Fill( &center, size.width/2, size.height/2, 0.0f )。x, y坐标与eye的相同,因此视线平行于Z轴。

最后的up参数可以理解为头顶方向,这里设置为Y轴方向。

可以看出,eye就在投影区的中心,由于投影区的高度为size.height(投影变换时分析得到的),这样根据投影矩阵设置的宽高比,得出该投影区的宽度也恰为size.width。

七、再分析

有了以上关于Cocos2d-x引擎的了解,我们再回过头来用OpenGL的变换原理对篇头的三种情况做分析。

 1) 屏幕大小480×320,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图充满窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域恰好是480×320;
        背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合,显示在屏幕上后,背景图恰充满窗口,见下图:

   
   
 2) 屏幕大小960×640,未做任何屏幕适配工作,不调用pEGLView->setDesignResolutionSize。结果:背景图未充满窗口,四周有较大黑边。
 
    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(480, 320, 480/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);

    因此背景图(480×320)未能完整充满截面区域(960×640),背景图周围将有较大黑边,见下图:
   
     

 3) 屏幕大小同为960×640,按照上面Cocos2d-x屏幕适配指南Wiki中的做法,调用pEGLView->setDesignResolutionSize(480, 320)。结果:背景图放大为原来2倍,充满屏幕窗口。

    在这种情况下,各个OpenGL变换矩阵参数值如下:
        eye视点坐标(240, 160, 320/1.1566f);
        投影变换矩阵在xy平面的截面区域是480×320;
        而背景图锚点位置(240, 160, 0);

    在这种情况下,截面区域恰与背景图重合。但这里需要注意的是现在屏幕是960×640,而截面区域仅仅是480×320,为何映射后,背景图充满屏幕了呢?这里就不能不提到视口的作用了。

    前面说过视口相当于相片,现在我们拍摄出的图片是480×320的,但我们选择的底片Viewport却是960×640的,怎么办,在视口转换 时,OpenGL自动将480×320的图片映射到960×640的底片上,相当于对图像进行的放大。而960×640的视口恰好与屏幕窗口大小一致且坐 标重叠,于是我们就在屏幕上看到了一个铺满屏幕的背景图,见下图:

   

 4) 我们再来说两个有关视口的例子

    以第三种情况为基础,我们修改一下引擎代码,看看视口的作用。
   
    我们手工将CCDirector::setViewport()中的:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width, m_obWinSizeInPoints.height);
    改为:
        m_pobOpenGLView->setViewPortInPoints(0, 0, m_obWinSizeInPoints.width/2, m_obWinSizeInPoints.height/2);

    这样修改后,Viewport从point(0,0), rect (960×640)变成了point(0,0), rect (480×320)。也就是说用照相机拍出的景物大小是480×320,底片也是480×320,但屏幕是960×640,我们可以将屏幕理解为相框,把 一张480×320的照片,放到960×640大小的相框里,相片只能占据相框的四分之一。这个例子的最终屏幕显示结果见下图:

   

    前面的例子中背景图片size均小于屏幕大小,我们再来举一个资源图片大于屏幕大小的例子,看看经过一系列变换会得到什么样的结果。
   
    首先将CCDirector::setViewport()中的代码恢复原先状态。然后我们准备一张1024×768(>屏幕的960×640)的 背景图片"HelloWorld-1024×768.jpg",修改HelloWorldScene.cpp,将:
    CCSprite* pSprite = CCSprite::create("HelloWorld.png");
    修改为:
    CCSprite* pSprite = CCSprite::create("HelloWorld-1024×768.png");

    注释掉AppDelegate.cpp中的pEGLView->setDesignResolutionSize调用,这样更直观。

    这样修改后,各参数如下:
        eye视点坐标(480, 320, 640/1.1566f);
        投影变换矩阵在xy平面的截面区域是960×640;
        而背景图锚点位置(480, 320, 0);
        Viewport point(0,0), rect (960×640)
   
    由于背景资源图片太大(1024×768),大于我们的投影截面区域960×640,因此模型真正能显示的部分仅仅是投影截面区域中的那960×640范围内的图片。于是显示结果如下:

   

    矩阵变换过程如下:

   

    投影截面区域与视口区域重叠,这里就不再赘述了。

八、CCDirector::m_fContentScaleFactor

决定图像在屏幕上的最终显示结果的因素还有一个,那就是CCDirector::m_fContentScaleFactor。在最初的HelloCpp例子中,我们能看到这样的代码:

    if (frameSize.height > mediumResource.size.height)
    {
        searchPath.push_back(largeResource.directory);
        pDirector->setContentScaleFactor(
          MIN(largeResource.size.height/designResolutionSize.height,
              largeResource.size.width/designResolutionSize.width));
    }
    … …

    可以看出这个contentScaleFactor存储的是资源分辨率与设计分辨率的比值。我们还是用例子来看看该元素对显示的影响。我们在第一种情况的基础上验证。

    第一种情况:屏幕480×320,未调用setDesignResolutionSize,资源大小480×320。结果:图片充满屏幕。

    现在我们增加并使用一个新资源:HelloWorld-960×640.png,这个图片大小960×640,是屏幕大小的二倍,根据上面的分析,我们很容易猜测到最终结果是:只有图片中央区域(480×320)可以显示出来,其余部分被投影矩阵截掉。

    现在我们使用setContentScaleFactor,在AppDelegate.cpp中做如下调用:

    pDirector->setContentScaleFactor(MIN(960/480, 640/320));

    这样我们得到的m_fContentScaleFactor = 2。而我们编译运行后得到的结果是:图片铺满整个屏幕。为什么会这样呢?

    我们在代码中搜索contentScaleFactor,我们找到一些宏和调用:

   
#define CC_CONTENT_SCALE_FACTOR() CCDirector::sharedDirector()->getContentScaleFactor()

CCSize CCTexture2D::getContentSize()
{

    CCSize ret;
    ret.width = m_tContentSize.width / CC_CONTENT_SCALE_FACTOR();
    ret.height = m_tContentSize.height / CC_CONTENT_SCALE_FACTOR();

    return ret;
}

#define CC_RECT_PIXELS_TO_POINTS(__rect_in_pixels__)                                                                        \
    CCRectMake( (__rect_in_pixels__).origin.x / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).origin.y / CC_CONTENT_SCALE_FACTOR(),    \
            (__rect_in_pixels__).size.width / CC_CONTENT_SCALE_FACTOR(), (__rect_in_pixels__).size.height / CC_CONTENT_SCALE_FACTOR() )

… …

bool CCSprite::initWithTexture(CCTexture2D *pTexture)
{
    CCAssert(pTexture != NULL, "Invalid texture for sprite");

    CCRect rect = CCRectZero;
    rect.size = pTexture->getContentSize();

    return initWithTexture(pTexture, rect);
}

    这些代码都在告诉我们,如果m_fContentScaleFactor = 2,那代码会对Sprite的纹理进行缩放,让上面得到的数据是经过contentScaleFactor变换的,我们可以认为我们所用的实际资源大小是 原资源的1/m_fContentScaleFactor即可。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats